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..0fb8430edbc --- /dev/null +++ b/desktop/src-tauri/src/commands/thread_titles.rs @@ -0,0 +1,345 @@ +//! 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::{BTreeMap, BTreeSet, HashMap, 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_title_list_filter, + 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}, +}; + +/// One relay page; the Threads view is a "recents" list, not an archive. +/// Header and timeline titles don't read this list (see [`get_thread_titles`]). +const LIST_LIMIT: u32 = 1000; +/// Roots per activity query. +const ACTIVITY_ROOT_BATCH: usize = 100; +/// Newest replies read per activity query. Roots absent from this window last +/// saw a reply before every returned one, so the top of the list stays exact. +const ACTIVITY_REPLY_WINDOW: u32 = 500; +/// Stream and forum reply kinds (edits and diffs are not new activity). +const REPLY_KINDS: [u16; 3] = [9, 40002, 45003]; +const CONFLICT_MESSAGE: &str = + "conflict: someone else changed this thread's title while you were editing"; + +#[derive(Debug, serde::Serialize)] +pub struct ThreadTitleEntry { + #[serde(flatten)] + title: ThreadTitle, + /// Unix seconds of the newest of: last reply, title revision. + last_activity_at: u64, +} + +#[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) +} + +/// `root -> newest reply time` for the given roots, from replies whose `e` +/// tags name them (NIP-10 replies tag their thread root). +fn last_reply_times(replies: &[nostr::Event], roots: &HashSet<&str>) -> HashMap<String, u64> { + let mut latest: HashMap<String, u64> = HashMap::new(); + for reply in replies { + let created_at = reply.created_at.as_secs(); + for tag in reply.tags.iter() { + if let [name, value, ..] = tag.as_slice() { + if name == "e" && roots.contains(value.as_str()) { + let entry = latest.entry(value.clone()).or_default(); + *entry = (*entry).max(created_at); + } + } + } + } + latest +} + +/// Newest activity first; ties keep the relay's newest-revision-first order. +fn sort_by_activity( + titles: Vec<ThreadTitle>, + last_replies: &HashMap<String, u64>, +) -> Vec<ThreadTitleEntry> { + let mut entries: Vec<_> = titles + .into_iter() + .map(|title| { + let last_reply = last_replies.get(&title.root).copied().unwrap_or(0); + ThreadTitleEntry { + last_activity_at: last_reply.max(title.updated_at), + title, + } + }) + .collect(); + entries.sort_by_key(|entry| std::cmp::Reverse(entry.last_activity_at)); + entries +} + +/// Every live thread title the user can read, most recently active first. +#[tauri::command] +pub async fn list_thread_titles( + state: State<'_, AppState>, +) -> Result<Vec<ThreadTitleEntry>, String> { + let events = query_relay(&state, &[thread_title_list_filter(None, LIST_LIMIT)]).await?; + let titles: Vec<ThreadTitle> = events.iter().filter_map(parse_thread_title).collect(); + + let mut roots_by_channel: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for title in &titles { + roots_by_channel + .entry(title.channel.as_str()) + .or_default() + .push(title.root.as_str()); + } + let all_roots: HashSet<&str> = titles.iter().map(|t| t.root.as_str()).collect(); + let mut replies = Vec::new(); + for (channel, roots) in &roots_by_channel { + for batch in roots.chunks(ACTIVITY_ROOT_BATCH) { + let filter = serde_json::json!({ + "kinds": REPLY_KINDS, + "#e": batch, + "#h": [channel], + "limit": ACTIVITY_REPLY_WINDOW, + }); + // Activity only refines the order; a failed lookup falls back to + // the title's own timestamp instead of hiding the list. + match query_relay(&state, &[filter]).await { + Ok(events) => replies.extend(events), + Err(e) => eprintln!("buzz-desktop: thread activity lookup failed: {e}"), + } + } + } + let last_replies = last_reply_times(&replies, &all_roots); + Ok(sort_by_activity(titles, &last_replies)) +} + +/// 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, Timestamp}; + + fn title(root: &str, updated_at: u64) -> ThreadTitle { + ThreadTitle { + title: root.into(), + channel: "c".into(), + root: root.into(), + artifact: "a".into(), + revision: "r".into(), + author: "p".into(), + updated_at, + } + } + + fn reply(created_at: u64, tags: &[[&str; 2]]) -> nostr::Event { + EventBuilder::new(Kind::Custom(9), "reply") + .tags(tags.iter().map(|t| Tag::parse(*t).unwrap())) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(&Keys::generate()) + .unwrap() + } + + #[test] + fn recent_replies_outrank_recent_titles() { + let roots: HashSet<&str> = ["old-title-busy", "new-title-quiet", "no-replies"].into(); + let replies = last_reply_times( + &[ + reply(400, &[["e", "old-title-busy"]]), + // Nested reply: root plus a parent that isn't a titled root. + reply(500, &[["e", "old-title-busy"], ["e", "some-parent"]]), + reply(900, &[["e", "untitled-root"]]), + ], + &roots, + ); + assert_eq!(replies.len(), 1); + let sorted = sort_by_activity( + vec![ + title("new-title-quiet", 300), + title("old-title-busy", 100), + title("no-replies", 200), + ], + &replies, + ); + let order: Vec<_> = sorted.iter().map(|e| e.title.root.as_str()).collect(); + assert_eq!(order, ["old-title-busy", "new-title-quiet", "no-replies"]); + assert_eq!(sorted[0].last_activity_at, 500); + } + + #[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..c74b909c988 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -650,6 +650,9 @@ pub fn run() { get_canvas, get_canvas_history, set_canvas, + list_thread_titles, + get_thread_titles, + set_thread_title, get_feed, search_messages, send_channel_message, diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index 9fc14736c7c..c82405380fd 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -9,7 +9,8 @@ export type AppView = | "agents" | "workflows" | "pulse" - | "projects"; + | "projects" + | "threads"; const WINDOW_DRAG_HANDLE_HEIGHT = 44; const TAURI_DRAG_REGION_ATTR = "data-tauri-drag-region"; @@ -254,6 +255,13 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/threads") { + return { + selectedChannelId: null, + selectedView: "threads", + }; + } + if (pathname === "/pulse") { return { selectedChannelId: null, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 97f37676475..4140a766a30 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -152,6 +152,7 @@ export function AppShell() { goProjects, goPulse, goSettings, + goThreads, goWorkflows, closeSettings, openSearchHit, @@ -894,6 +895,7 @@ export function AppShell() { onSelectProjects={() => void goProjects()} onSelectPulse={() => void goPulse()} onSelectSettings={handleOpenSettings} + onSelectThreads={() => void goThreads()} onSelectWorkflows={() => void goWorkflows()} onSetPresenceStatus={(status) => presenceSession.setStatus(status) diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index ade8c9332c0..39c4ade47fa 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -104,6 +104,27 @@ export function useAppNavigation() { [commitNavigation], ); + const goThreads = React.useCallback( + (behavior?: NavigationBehavior) => + // Re-selecting Threads while on it closes any open thread, so carry a + // fresh id the screen can react to instead of no-oping. + location.pathname === "/threads" + ? commitNavigation( + { + to: "/threads", + state: { threadsResetId: crypto.randomUUID() }, + }, + { ...behavior, replace: true }, + ) + : commitNavigation( + { + to: "/threads", + }, + behavior, + ), + [commitNavigation, location.pathname], + ); + const goProfile = React.useCallback( (pubkey: string, behavior?: NavigationBehavior) => commitNavigation( @@ -472,6 +493,7 @@ export function useAppNavigation() { goPulse, goProfile, goSettings, + goThreads, goWorkflow, goWorkflows, openSearchHit, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index 2bc2c8ddb6d..cba69e9c727 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -6,6 +6,7 @@ import { Route as rootRouteImport } from "./routes/root"; import { Route as workflowsRouteImport } from "./routes/workflows"; +import { Route as threadsRouteImport } from "./routes/threads"; import { Route as settingsRouteImport } from "./routes/settings"; import { Route as remindersRouteImport } from "./routes/reminders"; import { Route as pulseRouteImport } from "./routes/pulse"; @@ -23,6 +24,11 @@ const workflowsRoute = workflowsRouteImport.update({ path: "/workflows", getParentRoute: () => rootRouteImport, } as any); +const threadsRoute = threadsRouteImport.update({ + id: "/threads", + path: "/threads", + getParentRoute: () => rootRouteImport, +} as any); const settingsRoute = settingsRouteImport.update({ id: "/settings", path: "/settings", @@ -87,6 +93,7 @@ export interface FileRoutesByFullPath { "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; "/settings": typeof settingsRoute; + "/threads": typeof threadsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; "/messages/new": typeof messagesDotnewRoute; @@ -101,6 +108,7 @@ export interface FileRoutesByTo { "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; "/settings": typeof settingsRoute; + "/threads": typeof threadsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; "/messages/new": typeof messagesDotnewRoute; @@ -116,6 +124,7 @@ export interface FileRoutesById { "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; "/settings": typeof settingsRoute; + "/threads": typeof threadsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; "/messages/new": typeof messagesDotnewRoute; @@ -132,6 +141,7 @@ export interface FileRouteTypes { | "/pulse" | "/reminders" | "/settings" + | "/threads" | "/workflows" | "/channels/$channelId" | "/messages/new" @@ -146,6 +156,7 @@ export interface FileRouteTypes { | "/pulse" | "/reminders" | "/settings" + | "/threads" | "/workflows" | "/channels/$channelId" | "/messages/new" @@ -160,6 +171,7 @@ export interface FileRouteTypes { | "/pulse" | "/reminders" | "/settings" + | "/threads" | "/workflows" | "/channels/$channelId" | "/messages/new" @@ -175,6 +187,7 @@ export interface RootRouteChildren { pulseRoute: typeof pulseRoute; remindersRoute: typeof remindersRoute; settingsRoute: typeof settingsRoute; + threadsRoute: typeof threadsRoute; workflowsRoute: typeof workflowsRoute; channelsDotchannelIdRoute: typeof channelsDotchannelIdRoute; messagesDotnewRoute: typeof messagesDotnewRoute; @@ -192,6 +205,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof workflowsRouteImport; parentRoute: typeof rootRouteImport; }; + "/threads": { + id: "/threads"; + path: "/threads"; + fullPath: "/threads"; + preLoaderRoute: typeof threadsRouteImport; + parentRoute: typeof rootRouteImport; + }; "/settings": { id: "/settings"; path: "/settings"; @@ -279,6 +299,7 @@ const rootRouteChildren: RootRouteChildren = { pulseRoute: pulseRoute, remindersRoute: remindersRoute, settingsRoute: settingsRoute, + threadsRoute: threadsRoute, workflowsRoute: workflowsRoute, channelsDotchannelIdRoute: channelsDotchannelIdRoute, messagesDotnewRoute: messagesDotnewRoute, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index f5c6938e11a..d1dc15e73ca 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -4,6 +4,7 @@ export const routes = rootRoute("root.tsx", [ index("index.tsx"), route("/agents", "agents.tsx"), route("/pulse", "pulse.tsx"), + route("/threads", "threads.tsx"), route("/reminders", "reminders.tsx"), route("/settings", "settings.tsx"), route("/workflows", "workflows.tsx"), diff --git a/desktop/src/app/routes/threads.tsx b/desktop/src/app/routes/threads.tsx new file mode 100644 index 00000000000..a63dcbf0ed8 --- /dev/null +++ b/desktop/src/app/routes/threads.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const ThreadsScreen = React.lazy(async () => { + const module = await import("@/features/threads/ui/ThreadsScreen"); + return { default: module.ThreadsScreen }; +}); + +export const Route = createFileRoute("/threads")({ + component: ThreadsRouteComponent, +}); + +function ThreadsRouteComponent() { + return ( + <React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}> + <ThreadsScreen /> + </React.Suspense> + ); +} diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index 2658469b918..1adae85a510 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -6,6 +6,7 @@ import { syncActiveAgentTurnsFromObserver, getActiveTurnsForAgent, getActiveTurnsByChannel, + getActiveTurnsByThread, resetActiveAgentTurnsStore, subscribeActiveAgentTurns, saveActiveAgentTurnsForCommunity, @@ -1582,6 +1583,123 @@ describe("activeAgentTurnsStore", () => { * through the real observer store (appendAgentEvent path) and the same sync * the bridge hook runs, asserting derived liveness clears with the raw feed. */ +describe("active turns by thread", () => { + beforeEach(() => { + resetActiveAgentTurnsStore(); + }); + + const inThread = (root, overrides = {}) => + makeEvent({ payload: { threadRootEventId: root }, ...overrides }); + + it("keys a thread-scoped turn by its root", () => { + syncAgentTurnsFromEvents(AGENT, [inThread("root-a")]); + const threads = getActiveTurnsByThread(); + assert.deepEqual([...threads.keys()], ["root-a"]); + const summary = threads.get("root-a"); + assert.equal(summary.channelId, "chan-1"); + assert.equal(summary.agentCount, 1); + assert.deepEqual(summary.agentPubkeys, [AGENT]); + }); + + it("leaves channel-scoped and malformed-root turns out", () => { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }), + makeEvent({ + seq: 2, + turnId: "t2", + channelId: "c2", + payload: { threadRootEventId: 42 }, + }), + makeEvent({ + seq: 3, + turnId: "t3", + channelId: "c3", + payload: { threadRootEventId: "" }, + }), + ]); + assert.equal(getActiveTurnsByThread().size, 0); + assert.equal(getActiveTurnsByChannel().length, 3); + }); + + it("aggregates agents in one thread at the earliest start", () => { + syncAgentTurnsFromEvents(AGENT, [ + inThread("root-a", { timestamp: "2024-01-01T00:01:00Z" }), + ]); + syncAgentTurnsFromEvents(AGENT_2, [ + inThread("root-a", { timestamp: "2024-01-01T00:00:00Z" }), + ]); + const summary = getActiveTurnsByThread().get("root-a"); + assert.equal(summary.agentCount, 2); + const byAgent = (pubkey) => getActiveTurnsForAgent(pubkey)[0].anchorAt; + assert.equal(summary.anchorAt, Math.min(byAgent(AGENT), byAgent(AGENT_2))); + }); + + it("separates concurrent threads in one channel", () => { + syncAgentTurnsFromEvents(AGENT, [ + inThread("root-a", { seq: 1, turnId: "t1" }), + inThread("root-b", { seq: 2, turnId: "t2" }), + ]); + assert.deepEqual([...getActiveTurnsByThread().keys()].sort(), [ + "root-a", + "root-b", + ]); + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 3, kind: "turn_completed", turnId: "t1" }), + ]); + assert.deepEqual([...getActiveTurnsByThread().keys()], ["root-b"]); + }); + + it("learns the thread from liveness and notifies", () => { + syncAgentTurnsFromEvents(AGENT, [makeEvent({ seq: 1 })]); + assert.equal(getActiveTurnsByThread().size, 0); + let notified = 0; + const unsubscribe = subscribeActiveAgentTurns(() => notified++); + try { + syncAgentTurnsFromEvents(AGENT, [ + inThread("root-a", { seq: 2, kind: "turn_liveness" }), + ]); + } finally { + unsubscribe(); + } + assert.ok(notified > 0); + assert.deepEqual([...getActiveTurnsByThread().keys()], ["root-a"]); + }); + + it("keeps the thread when a pruned turn is resurrected by liveness", () => { + syncAgentTurnsFromEvents(AGENT, [ + inThread("root-a", { + seq: 1, + kind: "turn_liveness", + startedAt: "2024-01-01T00:00:00Z", + }), + ]); + assert.deepEqual([...getActiveTurnsByThread().keys()], ["root-a"]); + }); + + it("returns a stable reference until the turns change", () => { + syncAgentTurnsFromEvents(AGENT, [inThread("root-a", { seq: 1 })]); + const first = getActiveTurnsByThread(); + syncAgentTurnsFromEvents(AGENT, [ + inThread("root-a", { seq: 2, kind: "turn_liveness" }), + ]); + assert.equal(getActiveTurnsByThread(), first); + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 3, kind: "turn_completed" }), + ]); + assert.notEqual(getActiveTurnsByThread(), first); + assert.equal(getActiveTurnsByThread().size, 0); + }); + + it("carries the thread through a community switch", () => { + syncAgentTurnsFromEvents(AGENT, [inThread("root-a")]); + saveActiveAgentTurnsForCommunity("community-1"); + resetActiveAgentTurnsStore(); + assert.equal(getActiveTurnsByThread().size, 0); + restoreActiveAgentTurnsForCommunity("community-1"); + assert.deepEqual([...getActiveTurnsByThread().keys()], ["root-a"]); + }); +}); + describe("observer → active-turns bridge sync", () => { const bridgeAgents = [{ pubkey: AGENT, status: "deployed" }]; diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index ebafb3f1976..0b203e96e3d 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -54,6 +54,8 @@ const PRUNE_INTERVAL_MS = 5_000; type ActiveTurn = { turnId: string; channelId: string; + /** Thread the turn runs in (thread-scoped sessions only; #8029). */ + threadRootEventId: string | null; startedAt: number; lastActivityAt: number; }; @@ -73,6 +75,11 @@ export type ActiveChannelTurnSummary = { agentNames?: string[]; }; +/** One thread with active agent work, aggregated across agents. */ +export type ActiveThreadTurnSummary = ActiveChannelTurnSummary & { + threadRootEventId: string; +}; + // Module-level state: agentPubkey → turnId → ActiveTurn const activeTurnsByAgent = new Map<string, Map<string, ActiveTurn>>(); const listeners = new Set<() => void>(); @@ -98,6 +105,10 @@ const clockOffsetByAgent = new Map<string, number>(); // Only regenerated when the underlying turn map for an agent actually changes. const cachedTurnSummaries = new Map<string, ActiveTurnSummary[]>(); let cachedChannelTurnSummaries: ActiveChannelTurnSummary[] | null = null; +let cachedThreadTurnSummaries: ReadonlyMap< + string, + ActiveThreadTurnSummary +> | null = null; // Composite watermark per (agent, channel): the newest observer event // processed for that channel, by (timestamp, seq) ordering. An event is @@ -144,6 +155,7 @@ let unsubscribePruneVisibility: (() => void) | null = null; function invalidateCache(agentKey: string) { cachedTurnSummaries.delete(agentKey); cachedChannelTurnSummaries = null; + cachedThreadTurnSummaries = null; } function notifyListeners() { @@ -169,6 +181,14 @@ function sampleClockOffset(agentKey: string, timestamp: string): boolean { return true; } +/** The harness's `threadRootEventId` payload field, when present. */ +function threadRootOf(event: ObserverEvent): string | null { + const payload = event.payload; + if (!payload || typeof payload !== "object") return null; + const root = (payload as { threadRootEventId?: unknown }).threadRootEventId; + return typeof root === "string" && root.length > 0 ? root : null; +} + function parseTimestamp(timestamp: string): number | null { const parsed = Date.parse(timestamp); return Number.isFinite(parsed) ? parsed : null; @@ -179,6 +199,7 @@ function startTurn( channelId: string, turnId: string, timestamp: string, + threadRootEventId: string | null = null, ) { const key = normalizePubkey(agentPubkey); let agentTurns = activeTurnsByAgent.get(key); @@ -206,23 +227,35 @@ function startTurn( agentTurns.set(turnId, { turnId, channelId, + threadRootEventId, startedAt, lastActivityAt: Date.now(), }); invalidateCache(key); } -function recordActivity(agentPubkey: string, turnId: string | null): boolean { - if (!turnId) return false; +type ActivityResult = "missing" | "refreshed" | "thread-learned"; + +function recordActivity( + agentPubkey: string, + turnId: string | null, + threadRootEventId: string | null, +): ActivityResult { + if (!turnId) return "missing"; const key = normalizePubkey(agentPubkey); const agentTurns = activeTurnsByAgent.get(key); - if (!agentTurns) return false; + if (!agentTurns) return "missing"; const turn = agentTurns.get(turnId); - if (turn) { - turn.lastActivityAt = Date.now(); - return true; + if (!turn) return "missing"; + turn.lastActivityAt = Date.now(); + // A turn first seen without its thread (e.g. turn_started dropped from the + // buffer) learns it from liveness, which carries the same root. + if (threadRootEventId && turn.threadRootEventId !== threadRootEventId) { + turn.threadRootEventId = threadRootEventId; + invalidateCache(key); + return "thread-learned"; } - return false; + return "refreshed"; } /** @@ -254,7 +287,13 @@ function resurrectTurn(agentPubkey: string, event: ObserverEvent): boolean { frameAt !== null && startedAtMs !== null && startedAtMs <= frameAt ? startedAt : event.timestamp; - startTurn(agentPubkey, event.channelId, event.turnId, safeStartedAt); + startTurn( + agentPubkey, + event.channelId, + event.turnId, + safeStartedAt, + threadRootOf(event), + ); return true; } @@ -408,6 +447,7 @@ function processEvent(agentPubkey: string, event: ObserverEvent) { event.channelId, event.turnId ?? `seq-${event.seq}`, event.timestamp, + threadRootOf(event), ); notifyListeners(); return; @@ -432,8 +472,16 @@ function processEvent(agentPubkey: string, event: ObserverEvent) { // turn was pruned out from under a still-running host (a transient drop // raced the pause, or the lone-crash residual self-healed), resurrect it. case "turn_liveness": { - const refreshed = recordActivity(agentPubkey, event.turnId ?? null); - if (!refreshed && resurrectTurn(agentPubkey, event)) { + const activity = recordActivity( + agentPubkey, + event.turnId ?? null, + threadRootOf(event), + ); + if (activity === "thread-learned") { + notifyListeners(); + return; + } + if (activity === "missing" && resurrectTurn(agentPubkey, event)) { notifyListeners(); return; } @@ -576,6 +624,58 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] { return result; } +const EMPTY_THREAD_TURNS: ReadonlyMap<string, ActiveThreadTurnSummary> = + new Map(); + +/** + * Returns active working threads across all tracked agents, keyed by thread + * root event id and anchored to the earliest live turn in each thread. Turns + * without a thread root (channel-scoped or heartbeat) are not included. + */ +export function getActiveTurnsByThread(): ReadonlyMap< + string, + ActiveThreadTurnSummary +> { + if (cachedThreadTurnSummaries) return cachedThreadTurnSummaries; + if (activeTurnsByAgent.size === 0) return EMPTY_THREAD_TURNS; + + const summaries = new Map< + string, + { channelId: string; anchorAt: number; agentPubkeys: Set<string> } + >(); + for (const [agentKey, agentTurns] of activeTurnsByAgent) { + const offset = clockOffsetByAgent.get(agentKey) ?? 0; + for (const turn of agentTurns.values()) { + if (!turn.threadRootEventId) continue; + const anchorAt = turn.startedAt + offset; + const summary = summaries.get(turn.threadRootEventId); + if (!summary) { + summaries.set(turn.threadRootEventId, { + channelId: turn.channelId, + anchorAt, + agentPubkeys: new Set([agentKey]), + }); + continue; + } + summary.agentPubkeys.add(agentKey); + if (anchorAt < summary.anchorAt) summary.anchorAt = anchorAt; + } + } + + const result = new Map<string, ActiveThreadTurnSummary>(); + for (const [threadRootEventId, summary] of summaries) { + result.set(threadRootEventId, { + threadRootEventId, + channelId: summary.channelId, + anchorAt: summary.anchorAt, + agentCount: summary.agentPubkeys.size, + agentPubkeys: [...summary.agentPubkeys].sort(), + }); + } + cachedThreadTurnSummaries = result.size === 0 ? EMPTY_THREAD_TURNS : result; + return cachedThreadTurnSummaries; +} + /** * Synchronize the active-turns store with the latest observer events for a * given agent. @@ -616,6 +716,20 @@ export function useActiveAgentTurnsByChannel(): ActiveChannelTurnSummary[] { ); } +/** + * Hook: returns threads with active agent work, keyed by thread root event id. + * Re-renders when the thread set changes — not when the clock ticks. + */ +export function useActiveAgentTurnsByThread(): ReadonlyMap< + string, + ActiveThreadTurnSummary +> { + return React.useSyncExternalStore( + subscribeActiveAgentTurns, + getActiveTurnsByThread, + ); +} + /** * Sync every running/deployed agent's observer events into the active-turns * store. Extracted from the bridge hook so a regression can drive the exact @@ -710,6 +824,7 @@ export function resetActiveAgentTurnsStore() { clockOffsetByAgent.clear(); cachedTurnSummaries.clear(); cachedChannelTurnSummaries = null; + cachedThreadTurnSummaries = null; terminalAtByAgent.clear(); notifyListeners(); } @@ -826,6 +941,7 @@ export function restoreActiveAgentTurnsForCommunity(communityId: string): void { cachedTurnSummaries.clear(); cachedChannelTurnSummaries = null; + cachedThreadTurnSummaries = null; notifyListeners(); } diff --git a/desktop/src/features/channels/lib/threadPanelLayout.ts b/desktop/src/features/channels/lib/threadPanelLayout.ts index 3d441bdaf61..d07a2b5ca66 100644 --- a/desktop/src/features/channels/lib/threadPanelLayout.ts +++ b/desktop/src/features/channels/lib/threadPanelLayout.ts @@ -19,6 +19,8 @@ export type ThreadPanelLayoutProps = { onResizeStart?: React.PointerEventHandler<HTMLButtonElement>; splitPaneClamp?: boolean; testId?: string; + /** False shows the shared title read-only, e.g. when the header links out. */ + threadTitleEditable?: boolean; transparentChrome?: boolean; }; diff --git a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx index 82ecfe64400..cdbf8b99236 100644 --- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx +++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx @@ -9,6 +9,8 @@ import { getThreadViewMode } from "@/features/channels/lib/threadViewModePrefere import { cn } from "@/shared/lib/cn"; type FocusThreadDrawerProps = { + /** Names the scrim's click target. Defaults to `Back to #<channelName>`. */ + backLabel?: string; channelName: string; children: React.ReactNode; /** Prevent a covered drawer from handling Escape before its overlay. */ @@ -184,6 +186,7 @@ function useViewportRightInsetPx( * isolated. */ export function FocusThreadDrawer({ + backLabel, channelName, children, escapeEnabled = true, @@ -256,7 +259,7 @@ export function FocusThreadDrawer({ > <motion.button animate={{ opacity: 1 }} - aria-label={`Back to #${channelName}`} + aria-label={backLabel ?? `Back to #${channelName}`} className={cn( "absolute inset-0 cursor-pointer transition-colors duration-150", FOCUS_SCRIM_CLASS, 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/hooks.ts b/desktop/src/features/messages/hooks.ts index cbd6a8eea4d..4050d0e1596 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -811,6 +811,12 @@ export function useDeleteMessageMutation(channel: Channel | null) { channelMessagesKey(channel.id), (current = []) => current.filter((message) => message.id !== eventId), ); + // Thread panels opened outside the channel (Threads view, project + // conversations) read only the thread-replies cache. + queryClient.setQueriesData<RelayEvent[]>( + { queryKey: ["thread-replies", channel.id] }, + (current) => current?.filter((message) => message.id !== eventId), + ); }, onError: (error) => { toast.error(`Failed to delete message: ${error.message}`); 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..ccd1765978a 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"; @@ -207,6 +208,7 @@ export function MessageThreadPanel({ splitPaneClamp, showBackButton, testId = "message-thread-panel", + threadTitleEditable = true, widthPx, transparentChrome = false, autoSendDraftKey = null, @@ -223,6 +225,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 && threadTitleEditable + ? { channelId, rootId: threadRootId } + : null, + [channelId, disabled, threadRootId, threadTitleEditable], + ); + const titleSyncChannels = React.useMemo( + () => (isHuddleTranscript || !channelId ? [] : [channelId]), + [channelId, isHuddleTranscript], + ); + useThreadTitleLiveSync(titleSyncChannels); useEscapeKey( onClose, !isHuddleTranscript && (isOverlay || isSinglePanelView || isFocusMode), @@ -916,6 +932,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/projects/ui/ProjectConversationPanel.tsx b/desktop/src/features/projects/ui/ProjectConversationPanel.tsx index 11344ea574d..fd98621c7a7 100644 --- a/desktop/src/features/projects/ui/ProjectConversationPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectConversationPanel.tsx @@ -1,28 +1,51 @@ import { useQuery } from "@tanstack/react-query"; import * as React from "react"; +import { useAppShell } from "@/app/AppShellContext"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelMembersQuery, useChannelsQuery, } from "@/features/channels/hooks"; +import { getThreadPanelLayout } from "@/features/channels/lib/threadPanelLayout"; +import type { ThreadViewMode } from "@/features/channels/lib/threadViewModePreference"; +import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; +import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; +import { ThreadViewModeToggle } from "@/features/channels/ui/ThreadViewModeToggle"; +import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch"; import { useChannelSubscription, + useDeleteMessageMutation, + useEditMessageMutation, useSendMessageMutation, useToggleReactionMutation, } from "@/features/messages/hooks"; +import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMentionRefs"; +import { getRecentMentionPubkeys } from "@/features/messages/lib/recentMentionPubkeys"; +import { getSendToChannelSemantics } from "@/features/messages/lib/sendToChannelSemantics"; +import { summarizeThreadRoot } from "@/features/messages/lib/sentFromThread"; import { getThreadReference } from "@/features/messages/lib/threading"; import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel"; import { useThreadReplies } from "@/features/messages/useThreadReplies"; +import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel"; import { MessageThreadPanelSkeleton } from "@/features/messages/ui/MessageThreadPanelSkeleton"; import type { TimelineMessage } from "@/features/messages/types"; import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; +import { useConversationPanelActivity } from "@/features/projects/ui/useConversationPanelActivity"; +import { useThreadTitle } from "@/features/threads/hooks"; +import { + firstUnreadReplyId, + subtreeReplies, + unreadDescendantCounts, +} from "@/features/threads/lib/threadPaneUnread"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { SearchHit } from "@/shared/api/searchTypes"; import { getEventById } from "@/shared/api/tauri"; import type { RelayEvent, RespondToMode } from "@/shared/api/types"; +import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; +import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { Button } from "@/shared/ui/button"; @@ -33,22 +56,70 @@ async function loadConversationEvent(eventId: string) { return getEventById(eventId); } -/** Channel thread shown beside project task, review, and commit details. */ +/** The event to open and, when known, its thread root. */ +export type ConversationTarget = Pick< + SearchHit, + "channelId" | "channelName" | "eventId" | "threadRootId" +>; + +/** Opens the thread as the large focus drawer instead of the side pane. */ +export type ConversationFocusDrawer = { + /** Names the scrim behind the drawer, e.g. "Back to Threads". */ + backLabel: string; +}; + +/** Thread unread state from the surface that opened the pane. */ +export type ConversationUnread = { + /** Replies unread when the thread opened; anchors the "New" divider. */ + atOpen: ReadonlySet<string>; + /** Replies unread right now; drives the badges and the menu's toggle. */ + current: ReadonlySet<string>; + /** Receives the replies a "Mark read" covers. */ + onMarkRead: (replies: TimelineMessage[]) => void; +}; + +/** Channel thread shown beside project details and the Threads view. */ export function ProjectConversationPanel({ canResetWidth, + focusDrawer, + headerLeading, hit, onClose, + onMarkUnread, onResetWidth, onResizeStart, + onThreadViewModeChange, sharedHeaderBackdrop, + titleOpensThread = false, + unread, widthPx, }: { canResetWidth: boolean; - hit: SearchHit; + /** Ignored while the window is narrow enough for the floating overlay. */ + focusDrawer?: ConversationFocusDrawer; + headerLeading?: React.ReactNode; + hit: ConversationTarget; onClose: () => void; + /** + * Receives the reply ids a "Mark unread" covers: the chosen message's + * replies and, unless it is the root, the message itself. + */ + onMarkUnread?: (replyIds: string[]) => void; onResetWidth: () => void; onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void; + /** + * Shows the Focus/Split toggle in the header (replacing `headerLeading`) + * and keeps the reply being read in view across the switch, as channels do. + * Called after the preference changes. + */ + onThreadViewModeChange?: (mode: ThreadViewMode) => void; sharedHeaderBackdrop?: boolean; + /** + * The header shows the thread's title and opens the thread in its channel, + * like the Inbox header, instead of linking the channel with an editor. + */ + titleOpensThread?: boolean; + unread?: ConversationUnread; widthPx: number; }) { const { goChannel } = useAppNavigation(); @@ -122,6 +193,12 @@ export function ProjectConversationPanel({ const [scrollTargetId, setScrollTargetId] = React.useState<string | null>( hit.eventId === rootId ? null : hit.eventId, ); + const clearScrollTarget = React.useCallback( + () => setScrollTargetId(null), + [], + ); + const [editTargetId, setEditTargetId] = React.useState<string | null>(null); + const [emptyDeleteId, setEmptyDeleteId] = React.useState<string | null>(null); const threadIdentity = `${hit.eventId}:${rootId ?? ""}`; const threadIdentityRef = React.useRef(threadIdentity); if (threadIdentityRef.current !== threadIdentity) { @@ -130,6 +207,8 @@ export function ProjectConversationPanel({ setExpandedReplyIds(new Set()); setReplyTargetId(rootId); setScrollTargetId(hit.eventId === rootId ? null : hit.eventId); + setEditTargetId(null); + setEmptyDeleteId(null); } if ( expansionKey && @@ -162,11 +241,200 @@ export function ProjectConversationPanel({ identityQuery.data, ); const toggleReactionMutation = useToggleReactionMutation(); + const deleteMessageMutation = useDeleteMessageMutation(activeChannel); + const editMessageMutation = useEditMessageMutation(activeChannel); + const { followThread, isNotifiedForThread, unfollowThread } = useAppShell(); const isOverlay = useIsThreadPanelOverlay(); const isComposerDisabled = !activeChannel?.isMember || activeChannel.archivedAt !== null || activeChannel.channelType === "forum"; + const canManageMessages = Boolean(activeChannel && !activeChannel.archivedAt); + const editTargetMessage = editTargetId + ? (panelData.messages.find((message) => message.id === editTargetId) ?? + null) + : null; + const editTarget = editTargetMessage + ? buildMessageComposerEditTarget( + editTargetMessage, + profiles, + (pubkey) => !!profiles?.[pubkey]?.isAgent, + ) + : null; + const deleteMessage = React.useCallback( + async (message: { id: string }) => { + try { + await deleteMessageMutation.mutateAsync({ eventId: message.id }); + } catch { + // Failure is surfaced via the mutation's onError toast. + return; + } + // The root is loaded on its own, outside the replies cache, so a + // deleted root would linger; there is no thread left to show. + if (message.id === rootId) onClose(); + }, + [deleteMessageMutation.mutateAsync, onClose, rootId], + ); + const handleEdit = React.useCallback((message: { id: string }) => { + setEditTargetId((current) => (current === message.id ? null : message.id)); + }, []); + const currentPubkey = identityQuery.data?.pubkey; + // ArrowUp in an empty composer edits your newest visible message, as the + // channel's thread panel does (useRoutedMessageEdit). + const handleEditLastOwnMessage = React.useCallback(() => { + if (!currentPubkey || !panelData.threadHead) return false; + let target: TimelineMessage | null = null; + for (const message of [ + panelData.threadHead, + ...panelData.visibleReplies.map((entry) => entry.message), + ]) { + if ( + message.kind === KIND_SYSTEM_MESSAGE || + message.pubkey !== currentPubkey || + message.pending || + (target && message.createdAt < target.createdAt) + ) + continue; + target = message; + } + if (!target) return false; + setEditTargetId(target.id); + return true; + }, [currentPubkey, panelData.threadHead, panelData.visibleReplies]); + const handleEditSave = React.useCallback( + async ( + content: string, + mediaTags?: string[][], + mentionPubkeys?: string[], + ) => { + if (!editTargetId) return; + // Clearing an edit to empty means "Delete message", behind the same + // confirmation the menu's Delete shows (as in the channel view). + if (content.trim().length === 0 && !mediaTags?.length) { + setEmptyDeleteId(editTargetId); + return; + } + await editMessageMutation.mutateAsync({ + eventId: editTargetId, + content, + mediaTags, + mentionPubkeys, + }); + setEditTargetId((current) => (current === editTargetId ? null : current)); + }, + [editMessageMutation.mutateAsync, editTargetId], + ); + const handleSendToChannel = React.useCallback( + async ( + message: TimelineMessage, + threadRoot: TimelineMessage, + targetChannelId: string, + ) => { + const { mentionPubkeys, semanticTags } = getSendToChannelSemantics( + message, + profiles, + ); + await sendMessageMutation.mutateAsync({ + channelId: targetChannelId, + content: message.body, + mediaTags: semanticTags, + mentionPubkeys, + sentFromThreadRootExcerpt: summarizeThreadRoot(threadRoot.body), + sentFromThreadRootId: threadRoot.id, + }); + }, + [profiles, sendMessageMutation.mutateAsync], + ); + const handleMarkUnread = React.useCallback( + (message: TimelineMessage) => { + if (!onMarkUnread) return; + onMarkUnread( + subtreeReplies(message.id, rootId, panelData.messages).map( + (reply) => reply.id, + ), + ); + }, + [onMarkUnread, panelData.messages, rootId], + ); + const onMarkRead = unread?.onMarkRead; + const handleMarkRead = React.useCallback( + (message: TimelineMessage) => { + onMarkRead?.(subtreeReplies(message.id, rootId, panelData.messages)); + }, + [onMarkRead, panelData.messages, rootId], + ); + const unreadMarkers = React.useMemo(() => { + if (!unread) return null; + const replies = panelData.messages.filter( + (message) => message.id !== rootId, + ); + return { + firstUnreadReplyId: firstUnreadReplyId( + panelData.visibleReplies.map((entry) => entry.message), + unread.atOpen, + currentPubkey, + ), + threadUnreadCount: replies.filter((reply) => unread.current.has(reply.id)) + .length, + threadReplyUnreadCounts: unreadDescendantCounts( + panelData.messages, + unread.current, + ), + }; + }, [ + currentPubkey, + panelData.messages, + panelData.visibleReplies, + rootId, + unread, + ]); + const unreadCurrent = unread?.current; + const isMessageUnreadById = React.useCallback( + (messageId: string) => unreadCurrent?.has(messageId) ?? false, + [unreadCurrent], + ); + const recentMentionPubkeys = React.useMemo( + () => + getRecentMentionPubkeys( + [...panelData.messages].sort((a, b) => a.createdAt - b.createdAt), + activeChannel?.channelType, + ), + [activeChannel?.channelType, panelData.messages], + ); + const conversationEvents = React.useMemo( + () => (rootEvent ? [rootEvent, ...threadReplyEvents] : threadReplyEvents), + [rootEvent, threadReplyEvents], + ); + const activity = useConversationPanelActivity({ + activeChannel, + channelMembers: membersQuery.data, + currentPubkey, + events: conversationEvents, + rootId, + }); + const openAgentSession = React.useCallback( + (pubkey: string, sessionChannelId?: string | null) => { + const targetChannelId = sessionChannelId ?? channelId; + if (targetChannelId) { + void goChannel(targetChannelId, { agentSession: pubkey }); + } + }, + [channelId, goChannel], + ); + const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } = + useThreadViewModeSwitch({ + activeThreadHeadId: rootId, + externalScrollTargetId: scrollTargetId, + onExternalTargetResolved: clearScrollTarget, + onModeChange: onThreadViewModeChange, + }); + const panelHeaderLeading = + onThreadViewModeChange && !isOverlay ? ( + <ThreadViewModeToggle onChange={changeThreadViewMode} /> + ) : ( + headerLeading + ); + const isFollowingThread = rootId ? isNotifiedForThread(rootId) : false; const canShowThread = Boolean(activeChannel && panelData.threadHead) && (expansionKey == null || @@ -192,21 +460,66 @@ export function ProjectConversationPanel({ const openChannel = React.useCallback(() => { if (channelId) void goChannel(channelId); }, [channelId, goChannel]); + const sharedThreadTitle = useThreadTitle(channelId, rootId)?.title ?? null; + const openThreadInChannel = React.useCallback(() => { + if (channelId && rootId) { + void goChannel(channelId, { messageId: rootId, threadRootId: rootId }); + } + }, [channelId, goChannel, rootId]); + const threadLinkTitle = sharedThreadTitle + ? channelLabel + ? `${sharedThreadTitle} · #${channelLabel}` + : sharedThreadTitle + : channelLabel + ? `Thread in #${channelLabel}` + : "Thread"; + const activeFocusDrawer = isOverlay ? undefined : focusDrawer; const layoutProps = { canResetWidth, enterMotion: !canShowThread, - headerTitle: channelLabel ? `#${channelLabel}` : "Thread", - headerTitleAriaLabel: channelLabel ? `Open #${channelLabel}` : undefined, + headerLeading: panelHeaderLeading, + ...(titleOpensThread + ? { + headerTitle: threadLinkTitle, + headerTitleAriaLabel: "Open full thread", + onHeaderTitleClick: + channelId && rootId ? openThreadInChannel : undefined, + threadTitleEditable: false, + } + : { + headerTitle: channelLabel ? `#${channelLabel}` : "Thread", + headerTitleAriaLabel: channelLabel + ? `Open #${channelLabel}` + : undefined, + onHeaderTitleClick: channelId ? openChannel : undefined, + }), isFocusMode: false, isSinglePanelView: !isOverlay, layout: "standalone" as const, - onHeaderTitleClick: channelId ? openChannel : undefined, onResetWidth, onResizeStart, showBackButton: false, splitPaneClamp: false, testId: isOverlay ? "project-conversation-panel" : "message-thread-panel", transparentChrome: sharedHeaderBackdrop, + ...(activeFocusDrawer + ? getThreadPanelLayout({ + headerLeading: panelHeaderLeading, + isFocusDrawer: true, + isSinglePanelView: true, + useSplitAuxiliaryPane: true, + }) + : null), + }; + const paneOptions = { + canResetWidth, + channelName: activeChannel?.name ?? hit.channelName ?? "channel", + focusDrawer: activeFocusDrawer, + isOverlay, + onClose, + onResetWidth, + onResizeStart, + widthPx, }; const handleSend = React.useCallback( @@ -248,52 +561,103 @@ export function ProjectConversationPanel({ if (canShowThread && activeChannel && panelData.threadHead) { return wrapProjectConversationPane( - <MessageThreadPanel - {...layoutProps} - activityAccessoryVisible={false} - channel={activeChannel} - channelId={activeChannel.id} - channelName={activeChannel.name} - currentPubkey={identityQuery.data?.pubkey} - disabled={isComposerDisabled} - isSending={sendMessageMutation.isPending} - onCancelReply={() => setReplyTargetId(rootId)} - onClose={onClose} - onExpandReplies={handleExpandReplies} - onScrollTargetResolved={() => setScrollTargetId(null)} - onSelectReplyTarget={(message) => setReplyTargetId(message.id)} - onSend={handleSend} - onToggleReaction={ - isComposerDisabled - ? undefined - : async (message, emoji, remove) => { - await toggleReactionMutation.mutateAsync({ - emoji, - eventId: message.id, - remove, - }); - } - } - profiles={profiles} - replyTargetMessage={panelData.replyTargetMessage} - scrollTargetId={scrollTargetId} - threadHead={panelData.threadHead} - threadReplies={panelData.visibleReplies} - threadRepliesPending={threadRepliesQuery.isPending} - threadRepliesError={threadRepliesQuery.isError} - onRetryThreadReplies={() => { - void threadRepliesQuery.refetch(); - }} - threadTypingPubkeys={[]} - widthPx={widthPx} - />, - { - canResetWidth, - isOverlay, - onResetWidth, - onResizeStart, - widthPx, - }, + <AgentSessionProvider onOpenAgentSession={openAgentSession}> + <MessageThreadPanel + {...layoutProps} + activityAccessoryContent={ + activity.workingAgentPubkeys.length > 0 ? ( + <BotActivityComposerAction + agents={activity.agents} + channelId={activeChannel.id} + onOpenAgentSession={openAgentSession} + openAgentSessionPubkey={null} + profiles={profiles} + variant="inline" + workingBotPubkeys={activity.workingAgentPubkeys} + /> + ) : null + } + activityAccessoryVisible={activity.workingAgentPubkeys.length > 0} + channel={activeChannel} + channelId={activeChannel.id} + channelName={activeChannel.name} + currentPubkey={identityQuery.data?.pubkey} + disabled={isComposerDisabled} + editTarget={editTarget} + firstUnreadReplyId={unreadMarkers?.firstUnreadReplyId} + isFollowingThread={isFollowingThread} + isMessageUnreadById={unread ? isMessageUnreadById : undefined} + isSending={sendMessageMutation.isPending} + onCancelEdit={() => setEditTargetId(null)} + onCancelReply={() => setReplyTargetId(rootId)} + onClose={onClose} + onDelete={canManageMessages ? deleteMessage : undefined} + onEdit={canManageMessages ? handleEdit : undefined} + onEditSave={canManageMessages ? handleEditSave : undefined} + onEditLastOwnMessage={ + canManageMessages ? handleEditLastOwnMessage : undefined + } + onExpandReplies={handleExpandReplies} + onMarkRead={unread ? handleMarkRead : undefined} + onMarkUnread={onMarkUnread ? handleMarkUnread : undefined} + onFollowThread={ + rootId && !isFollowingThread + ? () => followThread(rootId) + : undefined + } + onUnfollowThread={ + rootId && isFollowingThread + ? () => unfollowThread(rootId) + : undefined + } + onSendToChannel={isComposerDisabled ? undefined : handleSendToChannel} + onScrollTargetResolved={() => resolveScrollTarget()} + onScrollTargetSettled={resolveScrollTarget} + onSelectReplyTarget={(message) => setReplyTargetId(message.id)} + onSend={handleSend} + onToggleReaction={ + isComposerDisabled + ? undefined + : async (message, emoji, remove) => { + await toggleReactionMutation.mutateAsync({ + emoji, + eventId: message.id, + remove, + }); + } + } + profiles={profiles} + recentMentionPubkeys={recentMentionPubkeys} + replyTargetMessage={panelData.replyTargetMessage} + scrollTargetHighlights={!layoutScrollTargetId} + scrollTargetId={layoutScrollTargetId ?? scrollTargetId} + threadHead={panelData.threadHead} + threadReplies={panelData.visibleReplies} + threadRepliesPending={threadRepliesQuery.isPending} + threadRepliesError={threadRepliesQuery.isError} + onRetryThreadReplies={() => { + void threadRepliesQuery.refetch(); + }} + threadReplyUnreadCounts={unreadMarkers?.threadReplyUnreadCounts} + threadTypingPubkeys={activity.threadTypingPubkeys} + threadUnreadCount={unreadMarkers?.threadUnreadCount} + widthPx={widthPx} + /> + <DeleteMessageConfirmDialog + onConfirm={() => { + if (emptyDeleteId) { + setEditTargetId(null); + void deleteMessage({ id: emptyDeleteId }); + } + setEmptyDeleteId(null); + }} + onOpenChange={(open) => { + if (!open) setEmptyDeleteId(null); + }} + open={emptyDeleteId !== null} + /> + </AgentSessionProvider>, + paneOptions, ); } @@ -304,13 +668,7 @@ export function ProjectConversationPanel({ onClose={onClose} widthPx={widthPx} />, - { - canResetWidth, - isOverlay, - onResetWidth, - onResizeStart, - widthPx, - }, + paneOptions, ); } @@ -323,13 +681,7 @@ export function ProjectConversationPanel({ Close </Button> </div>, - { - canResetWidth, - isOverlay, - onResetWidth, - onResizeStart, - widthPx, - }, + paneOptions, ); } @@ -337,19 +689,36 @@ function wrapProjectConversationPane( panel: React.ReactNode, { canResetWidth, + channelName, + focusDrawer, isOverlay, + onClose, onResetWidth, onResizeStart, widthPx, }: { canResetWidth: boolean; + channelName: string; + focusDrawer: ConversationFocusDrawer | undefined; isOverlay: boolean; + onClose: () => void; onResetWidth: () => void; onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void; widthPx: number; }, ) { if (isOverlay) return panel; + if (focusDrawer) { + return ( + <FocusThreadDrawer + backLabel={focusDrawer.backLabel} + channelName={channelName} + onClose={onClose} + > + {panel} + </FocusThreadDrawer> + ); + } return ( <RightAuxiliaryPane canResetWidth={canResetWidth} diff --git a/desktop/src/features/projects/ui/useConversationPanelActivity.ts b/desktop/src/features/projects/ui/useConversationPanelActivity.ts new file mode 100644 index 00000000000..12425313f94 --- /dev/null +++ b/desktop/src/features/projects/ui/useConversationPanelActivity.ts @@ -0,0 +1,99 @@ +import * as React from "react"; + +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { selectThreadComposerBotTypingPubkeys } from "@/features/channels/ui/ChannelPane.helpers"; +import { + buildChannelAgentSessionCandidates, + getChannelAgentSessionAgents, +} from "@/features/channels/ui/useChannelAgentSessions"; +import { useChannelTyping } from "@/features/messages/useChannelTyping"; +import { useRelaySelfQuery } from "@/features/moderation/hooks"; +import type { + Channel, + ChannelMember, + ManagedAgent, + RelayAgent, + RelayEvent, +} from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +const NO_MANAGED_AGENTS: ManagedAgent[] = []; +const NO_RELAY_AGENTS: RelayAgent[] = []; + +/** + * Who is typing or working in a thread shown outside its channel, split the + * way the channel's thread panel splits it (useChannelActivityTyping): people + * get the typing line, the channel's agents get the composer's activity chip. + * Unlike the channel hook this reports nothing to the shared working signal; + * the channel screen owns that. + */ +export function useConversationPanelActivity({ + activeChannel, + channelMembers, + currentPubkey, + events, + rootId, +}: { + activeChannel: Channel | null; + channelMembers: ChannelMember[] | undefined; + currentPubkey: string | undefined; + events: readonly RelayEvent[]; + rootId: string | null; +}) { + const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data; + // The newest event clears its author's typing line, as in the channel. + const latestEvent = React.useMemo( + () => + events.reduce<RelayEvent | null>( + (latest, event) => + !latest || event.created_at > latest.created_at ? event : latest, + null, + ), + [events], + ); + const typingEntries = useChannelTyping( + activeChannel, + currentPubkey, + latestEvent, + relaySelfPubkey, + ); + const managedAgents = useManagedAgentsQuery().data ?? NO_MANAGED_AGENTS; + const relayAgents = useRelayAgentsQuery().data ?? NO_RELAY_AGENTS; + const agents = React.useMemo( + () => + getChannelAgentSessionAgents({ + activeChannel, + activeChannelId: activeChannel?.id ?? null, + agents: buildChannelAgentSessionCandidates({ + channelMembers, + managedAgents, + relayAgents, + }), + channelMembers, + }), + [activeChannel, channelMembers, managedAgents, relayAgents], + ); + const { threadTypingPubkeys, workingAgentPubkeys } = React.useMemo(() => { + const agentPubkeys = new Set( + agents.map((agent) => normalizePubkey(agent.pubkey)), + ); + const isAgent = (pubkey: string) => + agentPubkeys.has(normalizePubkey(pubkey)); + return { + threadTypingPubkeys: typingEntries + .filter( + (entry) => entry.threadHeadId === rootId && !isAgent(entry.pubkey), + ) + .map((entry) => entry.pubkey), + workingAgentPubkeys: selectThreadComposerBotTypingPubkeys( + typingEntries.filter((entry) => isAgent(entry.pubkey)), + rootId, + ), + }; + }, [agents, rootId, typingEntries]); + + return { agents, threadTypingPubkeys, workingAgentPubkeys }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 4cc918e219f..c352545e8d0 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -120,6 +120,7 @@ export function AppSidebar({ onSelectAgents, onSelectProjects, onSelectPulse, + onSelectThreads, onSelectWorkflows, onSelectHome, onSelectChannel, @@ -571,6 +572,7 @@ export function AppSidebar({ onSelectHome={onSelectHome} onSelectProjects={onSelectProjects} onSelectPulse={onSelectPulse} + onSelectThreads={onSelectThreads} onSelectWorkflows={onSelectWorkflows} projectsOverviewActive={projectsOverviewActive} selectedView={selectedView} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.types.ts b/desktop/src/features/sidebar/ui/AppSidebar.types.ts index 029bafe6342..7da71d30531 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.types.ts +++ b/desktop/src/features/sidebar/ui/AppSidebar.types.ts @@ -45,7 +45,8 @@ export type AppSidebarProps = { | "agents" | "workflows" | "pulse" - | "projects"; + | "projects" + | "threads"; unreadChannelCounts: ReadonlyMap<string, number>; unreadChannelIds: ReadonlySet<string>; highPriorityUnreadChannelIds: ReadonlySet<string>; @@ -88,6 +89,7 @@ export type AppSidebarProps = { onSelectAgents: () => void; onSelectProjects: () => void; onSelectPulse: () => void; + onSelectThreads: () => void; onSelectWorkflows: () => void; onSelectHome: () => void; onSelectChannel: (channelId: string) => void; diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 660f7d9a8aa..2c42f03af5b 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -1,8 +1,26 @@ -import { Activity, Bot, Folders, Inbox, Zap } from "lucide-react"; +import * as React from "react"; +import { + Activity, + Bot, + Folders, + Inbox, + MessagesSquare, + Zap, +} from "lucide-react"; +import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { TopbarSearch } from "@/features/search/ui/TopbarSearch"; import { SidebarProjectsSection } from "@/features/sidebar/ui/SidebarProjectsSection"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { + useThreadTitleLiveSync, + useTitledThreadUnreadCounts, + useWorkingTitledThreads, +} from "@/features/threads/hooks"; +import type { WorkingTitledThreads } from "@/features/threads/lib/threadTitles"; import { FeatureGate } from "@/shared/features"; +import { cn } from "@/shared/lib/cn"; +import { useNow } from "@/shared/lib/useNow"; import type { Channel, SearchHit } from "@/shared/api/types"; import { SidebarHeader, @@ -21,7 +39,8 @@ type SidebarSelectedView = | "agents" | "workflows" | "pulse" - | "projects"; + | "projects" + | "threads"; type AppSidebarPinnedHeaderProps = { channelLabels: Record<string, string>; @@ -45,6 +64,7 @@ type AppSidebarPrimaryMenuProps = { onSelectHome: () => void; onSelectProjects: () => void; onSelectPulse: () => void; + onSelectThreads: () => void; onSelectWorkflows: () => void; projectsOverviewActive: boolean; selectedView: SidebarSelectedView; @@ -89,12 +109,103 @@ export function AppSidebarPinnedHeader({ ); } +/** The sidebar channel badge, rolled up across working threads. */ +function ThreadsWorkingBadge({ + isActive, + working, +}: { + isActive: boolean; + working: WorkingTitledThreads; +}) { + const now = useNow(1000); + const elapsed = formatElapsed(now - working.anchorAt); + const { threadCount } = working; + return ( + <span + className={cn( + "max-w-32 shrink-0 truncate rounded-full px-1.5 py-0.5 text-2xs font-medium leading-none tabular-nums motion-safe:animate-pulse group-data-[collapsible=icon]:hidden", + "hidden sm:inline-flex", + isActive + ? "bg-sidebar-active-foreground/20 text-sidebar-active-foreground" + : "bg-primary/10 text-primary", + )} + data-testid="threads-working" + title={`${threadCount} ${threadCount === 1 ? "thread" : "threads"} working`} + > + {threadCount > 1 ? `${elapsed} (${threadCount})` : elapsed} + </span> + ); +} + +/** + * Bold with a dot while any titled thread has unread replies, and a working + * timer while an agent runs in one, like a channel row. + */ +function ThreadsMenuItem({ + isActive, + onSelect, +}: { + isActive: boolean; + onSelect: () => void; +}) { + const channelsQuery = useChannelsQuery(); + // Titles change in channels the user isn't looking at (an agent naming a + // new thread); without this the dot waits for the next refetch. + const joinedChannelIds = React.useMemo( + () => + (channelsQuery.data ?? []) + .filter((channel) => channel.isMember) + .map((channel) => channel.id), + [channelsQuery.data], + ); + useThreadTitleLiveSync(joinedChannelIds); + const hasUnread = useTitledThreadUnreadCounts().size > 0; + const working = useWorkingTitledThreads(); + return ( + <SidebarMenuItem> + <SidebarMenuButton + className={cn( + "data-[active=true]:font-normal", + hasUnread && + "font-bold text-sidebar-foreground hover:text-sidebar-foreground data-[active=true]:font-bold", + )} + data-testid="open-threads-view" + isActive={isActive} + onClick={onSelect} + tooltip="Threads" + type="button" + > + <MessagesSquare className="h-4 w-4" /> + {/* Grows like a channel row's label, so the badge and dot sit right. */} + <SidebarMenuLabel className="flex-1">Threads</SidebarMenuLabel> + {working ? ( + <ThreadsWorkingBadge isActive={isActive} working={working} /> + ) : null} + {hasUnread ? ( + <span + // On the selected row's primary fill a primary dot vanishes, and + // that is exactly when a thread pane covers the list. + className={cn( + "ml-auto h-2 w-2 shrink-0 rounded-full", + isActive ? "bg-sidebar-active-foreground" : "bg-primary", + )} + data-testid="threads-unread-dot" + > + <span className="sr-only">unread</span> + </span> + ) : null} + </SidebarMenuButton> + </SidebarMenuItem> + ); +} + export function AppSidebarPrimaryMenu({ homeBadgeCount, onSelectAgents, onSelectHome, onSelectProjects, onSelectPulse, + onSelectThreads, onSelectWorkflows, projectsOverviewActive, selectedView, @@ -127,6 +238,10 @@ export function AppSidebarPrimaryMenu({ </SidebarMenuBadge> ) : null} </SidebarMenuItem> + <ThreadsMenuItem + isActive={selectedView === "threads"} + onSelect={onSelectThreads} + /> <FeatureGate feature="pulse"> <SidebarMenuItem> <SidebarMenuButton diff --git a/desktop/src/features/threads/hooks.ts b/desktop/src/features/threads/hooks.ts new file mode 100644 index 00000000000..ac34215e063 --- /dev/null +++ b/desktop/src/features/threads/hooks.ts @@ -0,0 +1,273 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { useAppShell } from "@/app/AppShellContext"; +import { useActiveAgentTurnsByThread } from "@/features/agents/activeAgentTurnsStore"; +import { getThreadReference } from "@/features/messages/lib/threading"; +import { + applyRootTitleWrite, + applyTitleWrite, + batchTitleSyncChannels, + countUnreadByTitledRoot, + createThreadTitleBatcher, + describeTitleSaveError, + summarizeWorkingTitledThreads, + THREAD_TITLES_QUERY_KEY, + threadTitleRootQueryKey, + type WorkingTitledThreads, +} from "@/features/threads/lib/threadTitles"; +import { relayClient } from "@/shared/api/relayClient"; +import { + getThreadTitles, + listThreadTitles, + setThreadTitle, + type ThreadTitle, + type ThreadTitleRecord, +} from "@/shared/api/tauriThreadTitles"; +import { KIND_ARTIFACT, KIND_ARTIFACT_REMOVAL } from "@/shared/constants/kinds"; + +/** Titles live in other channels too; poll so the Threads view stays current. */ +const THREAD_TITLES_REFRESH_MS = 60_000; +const LIVE_RETRY_MS = 5_000; +const THREAD_TITLE_STALE_MS = 30_000; + +const loadThreadTitle = createThreadTitleBatcher(getThreadTitles); + +/** Every titled thread the user can read, most recently active first. */ +export function useThreadTitlesQuery(options?: { refetchInterval?: boolean }) { + return useQuery({ + queryKey: THREAD_TITLES_QUERY_KEY, + queryFn: listThreadTitles, + staleTime: THREAD_TITLE_STALE_MS, + refetchInterval: options?.refetchInterval + ? THREAD_TITLES_REFRESH_MS + : false, + }); +} + +/** + * One root's live title, looked up by root rather than read from the Threads + * list, 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; +} + +/** + * Unread reply counts per titled thread, keyed by root id. Uses the same + * thread read state as the channel sidebar's unread dot, so the two agree. + */ +export function useTitledThreadUnreadCounts(): ReadonlyMap<string, number> { + const query = useThreadTitlesQuery(); + const { unreadThreadFeedItems } = useAppShell(); + return React.useMemo( + () => + countUnreadByTitledRoot( + query.data, + unreadThreadFeedItems.map((item) => ({ + id: item.id, + rootId: getThreadReference(item.tags).rootId, + })), + ), + [query.data, unreadThreadFeedItems], + ); +} + +/** Agent work in threads the Threads view lists, for its sidebar entry. */ +export function useWorkingTitledThreads(): WorkingTitledThreads | null { + const query = useThreadTitlesQuery(); + const workingThreads = useActiveAgentTurnsByThread(); + return React.useMemo( + () => summarizeWorkingTitledThreads(query.data, workingThreads), + [query.data, workingThreads], + ); +} + +/** + * Reads a thread while it is open outside its channel, including replies that + * arrive while it stays open. Mirrors Inbox: advance each reply's own marker + * and the thread frontier, and drop any manual "mark unread" override. + */ +export function useMarkOpenThreadRead(rootId: string | null) { + const { + feedItemState, + markMessageRead, + markThreadRead, + unreadThreadFeedItems, + } = useAppShell(); + const unread = React.useMemo( + () => + rootId + ? unreadThreadFeedItems.filter( + (item) => getThreadReference(item.tags).rootId === rootId, + ) + : [], + [rootId, unreadThreadFeedItems], + ); + + React.useEffect(() => { + if (!rootId || unread.length === 0) return; + let latest = 0; + for (const item of unread) { + feedItemState.undoUnread(item.id); + markMessageRead(item.id, item.createdAt); + latest = Math.max(latest, item.createdAt); + } + markThreadRead(rootId, latest); + }, [ + feedItemState.undoUnread, + markMessageRead, + markThreadRead, + rootId, + unread, + ]); +} + +export function useSetThreadTitleMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: setThreadTitle, + onMutate: async (write) => { + await queryClient.cancelQueries({ queryKey: THREAD_TITLES_QUERY_KEY }); + const rootKey = threadTitleRootQueryKey(write.rootId); + const previous = queryClient.getQueryData<ThreadTitle[]>( + THREAD_TITLES_QUERY_KEY, + ); + const previousRecord = queryClient.getQueryData<ThreadTitleRecord | null>( + rootKey, + ); + const now = Math.floor(Date.now() / 1000); + queryClient.setQueryData<ThreadTitle[]>(THREAD_TITLES_QUERY_KEY, (old) => + applyTitleWrite(old, write, now), + ); + queryClient.setQueryData<ThreadTitleRecord | null>(rootKey, (old) => + applyRootTitleWrite(old, write, now), + ); + return { previous, previousRecord }; + }, + onSuccess: (result, write) => { + const stored = { + ...write, + title: result.title, + revision: result.revision, + }; + const now = Math.floor(Date.now() / 1000); + queryClient.setQueryData<ThreadTitle[]>(THREAD_TITLES_QUERY_KEY, (old) => + applyTitleWrite(old, stored, now), + ); + queryClient.setQueryData<ThreadTitleRecord | null>( + threadTitleRootQueryKey(write.rootId), + (old) => applyRootTitleWrite(old, stored, now), + ); + }, + // Hook-level so the toast still shows when the editor unmounted on blur. + onError: (error, write, context) => { + queryClient.setQueryData(THREAD_TITLES_QUERY_KEY, context?.previous); + 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/threadChannelFilter.test.mjs b/desktop/src/features/threads/lib/threadChannelFilter.test.mjs new file mode 100644 index 00000000000..1c5d321c251 --- /dev/null +++ b/desktop/src/features/threads/lib/threadChannelFilter.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + effectiveChannelFilter, + filterByChannel, + threadChannelOptions, +} from "./threadChannelFilter.ts"; + +const LABELS = { c1: "#general", c2: "#buzz-dev", c3: "Alice" }; +const labelFor = (id) => LABELS[id] ?? "Unknown channel"; + +const entries = [ + { channelId: "c1", rootId: "r1" }, + { channelId: "c2", rootId: "r2" }, + { channelId: "c1", rootId: "r3" }, + { channelId: "c3", rootId: "r4" }, +]; + +test("threadChannelOptions counts threads per channel, sorted by label", () => { + assert.deepEqual(threadChannelOptions(entries, labelFor), [ + { channelId: "c2", count: 1, label: "#buzz-dev" }, + { channelId: "c1", count: 2, label: "#general" }, + { channelId: "c3", count: 1, label: "Alice" }, + ]); +}); + +test("threadChannelOptions is empty with no threads", () => { + assert.deepEqual(threadChannelOptions([], labelFor), []); +}); + +test("threadChannelOptions breaks label ties by channel id", () => { + const options = threadChannelOptions( + [{ channelId: "z" }, { channelId: "a" }], + () => "Unknown channel", + ); + assert.deepEqual( + options.map((option) => option.channelId), + ["a", "z"], + ); +}); + +test("filterByChannel keeps order and only the selected channel", () => { + assert.deepEqual( + filterByChannel(entries, "c1").map((entry) => entry.rootId), + ["r1", "r3"], + ); + assert.deepEqual(filterByChannel(entries, null), entries); +}); + +test("effectiveChannelFilter drops a channel that no longer has threads", () => { + const options = threadChannelOptions(entries, labelFor); + assert.equal(effectiveChannelFilter("c2", options), "c2"); + assert.equal(effectiveChannelFilter("gone", options), null); + assert.equal(effectiveChannelFilter(null, options), null); +}); diff --git a/desktop/src/features/threads/lib/threadChannelFilter.ts b/desktop/src/features/threads/lib/threadChannelFilter.ts new file mode 100644 index 00000000000..18e8d812428 --- /dev/null +++ b/desktop/src/features/threads/lib/threadChannelFilter.ts @@ -0,0 +1,53 @@ +export type ThreadChannelOption = { + channelId: string; + count: number; + label: string; +}; + +/** + * One option per channel that has a named thread, alphabetical by label so the + * menu doesn't reshuffle as threads get new activity. + */ +export function threadChannelOptions( + entries: readonly { channelId: string }[], + labelFor: (channelId: string) => string, +): ThreadChannelOption[] { + const counts = new Map<string, number>(); + for (const entry of entries) { + counts.set(entry.channelId, (counts.get(entry.channelId) ?? 0) + 1); + } + return [...counts] + .map(([channelId, count]) => ({ + channelId, + count, + label: labelFor(channelId), + })) + .sort( + (a, b) => + a.label.localeCompare(b.label) || + a.channelId.localeCompare(b.channelId), + ); +} + +/** + * A channel filter only applies while that channel still has a named thread; + * otherwise the view would sit empty behind a filter the menu no longer lists. + */ +export function effectiveChannelFilter( + selected: string | null, + options: readonly ThreadChannelOption[], +): string | null { + if (selected === null) return null; + return options.some((option) => option.channelId === selected) + ? selected + : null; +} + +export function filterByChannel<T extends { channelId: string }>( + entries: readonly T[], + channelId: string | null, +): T[] { + return channelId === null + ? [...entries] + : entries.filter((entry) => entry.channelId === channelId); +} diff --git a/desktop/src/features/threads/lib/threadPaneUnread.test.mjs b/desktop/src/features/threads/lib/threadPaneUnread.test.mjs new file mode 100644 index 00000000000..a802953f74d --- /dev/null +++ b/desktop/src/features/threads/lib/threadPaneUnread.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + firstUnreadReplyId, + subtreeReplies, + unreadDescendantCounts, + unreadReplyIdsForRoot, +} from "./threadPaneUnread.ts"; + +const ME = "a".repeat(64); +const THEM = "b".repeat(64); + +// root ─┬─ r1 ── r1a ── r1a1 +// └─ r2 +const MESSAGES = [ + { id: "root", parentId: null, createdAt: 1, pubkey: THEM }, + { id: "r1", parentId: "root", createdAt: 2, pubkey: THEM }, + { id: "r1a", parentId: "r1", createdAt: 3, pubkey: ME }, + { id: "r1a1", parentId: "r1a", createdAt: 4, pubkey: THEM }, + { id: "r2", parentId: "root", createdAt: 5, pubkey: THEM }, +]; + +test("unreadReplyIdsForRoot keeps only that thread's replies", () => { + const ids = unreadReplyIdsForRoot( + [ + { id: "r1", rootId: "root" }, + { id: "x1", rootId: "other" }, + { id: "root", rootId: "root" }, + { id: "r2", rootId: "root" }, + ], + "root", + ); + assert.deepEqual([...ids], ["r1", "r2"]); + assert.equal( + unreadReplyIdsForRoot([{ id: "r1", rootId: "root" }], null).size, + 0, + ); +}); + +test("firstUnreadReplyId follows visible order and skips your own replies", () => { + const replies = MESSAGES.slice(1); + assert.equal( + firstUnreadReplyId(replies, new Set(["r2", "r1a1"]), ME), + "r1a1", + ); + // Your own reply never starts the unread section. + assert.equal( + firstUnreadReplyId(replies, new Set(["r1a", "r2"]), ME.toUpperCase()), + "r2", + ); + assert.equal(firstUnreadReplyId(replies, new Set(), ME), null); +}); + +test("unreadDescendantCounts counts unread replies anywhere beneath a row", () => { + const counts = unreadDescendantCounts(MESSAGES, new Set(["r1a1", "r2"])); + assert.deepEqual(Object.fromEntries(counts), { root: 2, r1: 1, r1a: 1 }); + assert.equal(unreadDescendantCounts(MESSAGES, new Set()).size, 0); +}); + +test("subtreeReplies covers the subtree and the message unless it is the root", () => { + assert.deepEqual( + subtreeReplies("r1", "root", MESSAGES).map((message) => message.id), + ["r1", "r1a", "r1a1"], + ); + assert.deepEqual( + subtreeReplies("root", "root", MESSAGES).map((message) => message.id), + ["r1", "r1a", "r1a1", "r2"], + ); + assert.deepEqual( + subtreeReplies("r2", "root", MESSAGES).map((message) => message.id), + ["r2"], + ); +}); diff --git a/desktop/src/features/threads/lib/threadPaneUnread.ts b/desktop/src/features/threads/lib/threadPaneUnread.ts new file mode 100644 index 00000000000..633fd9ca2aa --- /dev/null +++ b/desktop/src/features/threads/lib/threadPaneUnread.ts @@ -0,0 +1,75 @@ +import { + buildDirectReplyIdsByParentId, + collectReplyDescendantIds, +} from "@/features/channels/lib/subtreeCreatedAt"; + +type UnreadFeedReply = { id: string; rootId: string | null }; +type PaneMessage = { id: string; parentId?: string | null; createdAt: number }; + +/** Ids of the unread replies the app tracks for one thread. */ +export function unreadReplyIdsForRoot( + items: readonly UnreadFeedReply[], + rootId: string | null, +): ReadonlySet<string> { + const ids = new Set<string>(); + if (!rootId) return ids; + for (const item of items) { + if (item.rootId === rootId && item.id !== rootId) ids.add(item.id); + } + return ids; +} + +/** + * Where the pane's "New" divider goes: the first visible reply that was unread + * when the thread opened, skipping your own replies as the channel does. + */ +export function firstUnreadReplyId( + replies: readonly { id: string; pubkey?: string }[], + unreadAtOpen: ReadonlySet<string>, + currentPubkey: string | undefined, +): string | null { + const self = currentPubkey?.toLowerCase(); + for (const reply of replies) { + if (unreadAtOpen.has(reply.id) && reply.pubkey?.toLowerCase() !== self) { + return reply.id; + } + } + return null; +} + +/** Unread replies beneath each message, for the collapsed-row badges. */ +export function unreadDescendantCounts( + messages: readonly PaneMessage[], + unreadIds: ReadonlySet<string>, +): ReadonlyMap<string, number> { + const counts = new Map<string, number>(); + if (unreadIds.size === 0) return counts; + const repliesByParent = buildDirectReplyIdsByParentId(messages); + for (const message of messages) { + let count = 0; + for (const id of collectReplyDescendantIds(message.id, repliesByParent)) { + if (unreadIds.has(id)) count += 1; + } + if (count > 0) counts.set(message.id, count); + } + return counts; +} + +/** + * The replies a "Mark read" or "Mark unread" on `messageId` covers: its whole + * subtree, plus the message itself unless it is the root (which is not a reply). + */ +export function subtreeReplies<T extends PaneMessage>( + messageId: string, + rootId: string | null, + messages: readonly T[], +): T[] { + const ids = new Set( + collectReplyDescendantIds( + messageId, + buildDirectReplyIdsByParentId(messages), + ), + ); + if (messageId !== rootId) ids.add(messageId); + return messages.filter((message) => ids.has(message.id)); +} diff --git a/desktop/src/features/threads/lib/threadPins.test.mjs b/desktop/src/features/threads/lib/threadPins.test.mjs new file mode 100644 index 00000000000..a42af1f0c16 --- /dev/null +++ b/desktop/src/features/threads/lib/threadPins.test.mjs @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + EMPTY_THREAD_PIN_STORE, + MAX_THREAD_PIN_ENTRIES, + mergeThreadPinStores, + parseThreadPinPayload, + partitionByPin, + pinnedRoots, + togglePin, +} from "./threadPins.ts"; + +const A = "a".repeat(64); +const B = "b".repeat(64); +const C = "c".repeat(64); + +function store(roots = {}) { + return { version: 1, roots }; +} + +test("togglePin pins, then unpins as a tombstone, normalizing the root", () => { + const pinned = togglePin(EMPTY_THREAD_PIN_STORE, A.toUpperCase(), 100); + assert.deepEqual(pinned, store({ [A]: { pinned: true, updatedAt: 100 } })); + assert.deepEqual( + togglePin(pinned, A, 200), + store({ [A]: { pinned: false, updatedAt: 200 } }), + ); + assert.deepEqual([...pinnedRoots(togglePin(pinned, A, 200))], []); +}); + +test("togglePin advances updatedAt even when the clock went backwards", () => { + const pinned = togglePin(EMPTY_THREAD_PIN_STORE, A, 500); + const unpinned = togglePin(pinned, A, 100); + assert.deepEqual(unpinned.roots[A], { pinned: false, updatedAt: 501 }); +}); + +test("the cap evicts tombstones before pins, then the oldest pins", () => { + let current = EMPTY_THREAD_PIN_STORE; + for (let i = 0; i < MAX_THREAD_PIN_ENTRIES; i++) { + current = togglePin(current, i.toString(16).padStart(64, "0"), 1_000 + i); + } + // Unpin the newest pin: it becomes the only tombstone. + const newest = (MAX_THREAD_PIN_ENTRIES - 1).toString(16).padStart(64, "0"); + current = togglePin(current, newest, 5_000); + current = togglePin(current, C, 10_000); + assert.equal(Object.keys(current.roots).length, MAX_THREAD_PIN_ENTRIES); + assert.ok(!(newest in current.roots), "the tombstone is evicted first"); + assert.ok(current.roots[C].pinned); + + current = togglePin(current, B, 11_000); + assert.ok( + !("0".repeat(64) in current.roots), + "then the oldest pin is evicted", + ); +}); + +test("merge takes the newer entry per root, from either side", () => { + const local = store({ + [A]: { pinned: true, updatedAt: 10 }, + [B]: { pinned: true, updatedAt: 10 }, + }); + const remote = store({ + [A]: { pinned: false, updatedAt: 20 }, + [B]: { pinned: false, updatedAt: 5 }, + [C]: { pinned: true, updatedAt: 1 }, + }); + assert.deepEqual( + mergeThreadPinStores(local, remote), + store({ + [A]: { pinned: false, updatedAt: 20 }, + [B]: { pinned: true, updatedAt: 10 }, + [C]: { pinned: true, updatedAt: 1 }, + }), + ); +}); + +test("an unpin on one device is not resurrected by another's stale pin", () => { + const deviceOne = togglePin(EMPTY_THREAD_PIN_STORE, A, 100); + const deviceTwo = togglePin(deviceOne, A, 200); + assert.deepEqual( + [...pinnedRoots(mergeThreadPinStores(deviceOne, deviceTwo))], + [], + ); + assert.deepEqual( + [...pinnedRoots(mergeThreadPinStores(deviceTwo, deviceOne))], + [], + ); +}); + +test("an exact-timestamp conflict resolves to unpinned", () => { + const merged = mergeThreadPinStores( + store({ [A]: { pinned: true, updatedAt: 7 } }), + store({ [A]: { pinned: false, updatedAt: 7 } }), + ); + assert.deepEqual(merged.roots[A], { pinned: false, updatedAt: 7 }); +}); + +test("partitionByPin keeps activity order within each group", () => { + const entries = [{ rootId: A }, { rootId: B.toUpperCase() }, { rootId: C }]; + const { pinned, unpinned } = partitionByPin(entries, new Set([C, B])); + assert.deepEqual( + pinned.map((entry) => entry.rootId), + [B.toUpperCase(), C], + ); + assert.deepEqual( + unpinned.map((entry) => entry.rootId), + [A], + ); +}); + +test("parseThreadPinPayload rejects malformed payloads and bad entries", () => { + assert.equal(parseThreadPinPayload(null), null); + assert.equal(parseThreadPinPayload([A]), null); + assert.equal(parseThreadPinPayload({ version: 2, roots: {} }), null); + assert.equal(parseThreadPinPayload({ version: 1, roots: [] }), null); + assert.deepEqual( + parseThreadPinPayload({ + version: 1, + roots: { + [A]: { pinned: true, updatedAt: 5 }, + short: { pinned: true, updatedAt: 5 }, + [A.toUpperCase()]: { pinned: true, updatedAt: 5 }, + [B]: { pinned: "yes", updatedAt: 5 }, + [C]: { pinned: true, updatedAt: -1 }, + }, + }), + store({ [A]: { pinned: true, updatedAt: 5 } }), + ); +}); diff --git a/desktop/src/features/threads/lib/threadPins.ts b/desktop/src/features/threads/lib/threadPins.ts new file mode 100644 index 00000000000..b9cca467599 --- /dev/null +++ b/desktop/src/features/threads/lib/threadPins.ts @@ -0,0 +1,179 @@ +/** + * Pinned threads are a personal view preference, not shared thread metadata, + * so they never touch the title artifact. The store is mirrored to + * localStorage and synced across the user's devices as a self-encrypted + * NIP-78 blob (see `threadPinsSync.ts`). Unpins are kept as tombstones so a + * per-root last-writer-wins merge cannot resurrect them. + */ +const THREAD_PINS_PREFIX = "buzz.threads.pins.v1"; +/** Upper bound on stored roots, pins and tombstones together. */ +export const MAX_THREAD_PIN_ENTRIES = 500; + +export type ThreadPinEntry = { pinned: boolean; updatedAt: number }; + +export type ThreadPinStore = { + version: 1; + /** Thread root id (lowercase hex) → latest pin state. */ + roots: Record<string, ThreadPinEntry>; +}; + +export const EMPTY_THREAD_PIN_STORE: ThreadPinStore = Object.freeze({ + version: 1, + roots: Object.freeze({}), +}) as ThreadPinStore; + +const ROOT_ID_RE = /^[0-9a-f]{64}$/; + +export function threadPinsStorageKey(relayOrigin: string, pubkey: string) { + return `${THREAD_PINS_PREFIX}.${encodeURIComponent(relayOrigin)}.${pubkey.toLowerCase()}`; +} + +export function parseThreadPinPayload(value: unknown): ThreadPinStore | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const { version, roots } = value as Record<string, unknown>; + if (version !== 1) return null; + if (!roots || typeof roots !== "object" || Array.isArray(roots)) return null; + return { + version: 1, + roots: Object.fromEntries( + Object.entries(roots as Record<string, unknown>).filter( + (entry): entry is [string, ThreadPinEntry] => { + const pin = entry[1] as Record<string, unknown> | null; + return ( + ROOT_ID_RE.test(entry[0]) && + typeof pin === "object" && + pin !== null && + typeof pin.pinned === "boolean" && + typeof pin.updatedAt === "number" && + Number.isFinite(pin.updatedAt) && + pin.updatedAt >= 0 + ); + }, + ), + ), + }; +} + +/** Keeps the store bounded: tombstones go first, then the oldest pins. */ +function capEntries(roots: Record<string, ThreadPinEntry>) { + const entries = Object.entries(roots); + if (entries.length <= MAX_THREAD_PIN_ENTRIES) return roots; + entries.sort( + ([, left], [, right]) => + Number(right.pinned) - Number(left.pinned) || + right.updatedAt - left.updatedAt, + ); + return Object.fromEntries(entries.slice(0, MAX_THREAD_PIN_ENTRIES)); +} + +export function togglePin( + store: ThreadPinStore, + rootId: string, + nowMs: number, +): ThreadPinStore { + const root = rootId.toLowerCase(); + const current = store.roots[root]; + // Strictly after the previous write, so a clock that stepped backwards + // still produces a toggle that wins the merge. + const updatedAt = Math.max(nowMs, (current?.updatedAt ?? 0) + 1); + return { + version: 1, + roots: capEntries({ + ...store.roots, + [root]: { pinned: !current?.pinned, updatedAt }, + }), + }; +} + +/** Per-root last-writer-wins; an exact tie resolves to unpinned. */ +export function mergeThreadPinStores( + local: ThreadPinStore, + remote: ThreadPinStore, +): ThreadPinStore { + const roots: Record<string, ThreadPinEntry> = { ...local.roots }; + for (const [root, remoteEntry] of Object.entries(remote.roots)) { + const localEntry = roots[root]; + if (!localEntry || remoteEntry.updatedAt > localEntry.updatedAt) { + roots[root] = remoteEntry; + } else if ( + remoteEntry.updatedAt === localEntry.updatedAt && + remoteEntry.pinned !== localEntry.pinned + ) { + roots[root] = { pinned: false, updatedAt: localEntry.updatedAt }; + } + } + return { version: 1, roots: capEntries(roots) }; +} + +export function threadPinStoresEqual( + left: ThreadPinStore, + right: ThreadPinStore, +): boolean { + const leftKeys = Object.keys(left.roots); + if (leftKeys.length !== Object.keys(right.roots).length) return false; + return leftKeys.every((root) => { + const rightEntry = right.roots[root]; + return ( + rightEntry !== undefined && + rightEntry.pinned === left.roots[root].pinned && + rightEntry.updatedAt === left.roots[root].updatedAt + ); + }); +} + +export function pinnedRoots(store: ThreadPinStore): ReadonlySet<string> { + return new Set( + Object.entries(store.roots) + .filter(([, entry]) => entry.pinned) + .map(([root]) => root), + ); +} + +/** + * Pinned threads first, then the rest. Both groups keep the incoming order, + * which is already most-recent-activity-first. + */ +export function partitionByPin<T extends { rootId: string }>( + entries: readonly T[], + pinned: ReadonlySet<string>, +): { pinned: T[]; unpinned: T[] } { + const pinnedEntries: T[] = []; + const unpinned: T[] = []; + for (const entry of entries) { + (pinned.has(entry.rootId.toLowerCase()) ? pinnedEntries : unpinned).push( + entry, + ); + } + return { pinned: pinnedEntries, unpinned }; +} + +export function readThreadPinStore( + relayOrigin: string, + pubkey: string, +): ThreadPinStore { + try { + const raw = globalThis.localStorage?.getItem( + threadPinsStorageKey(relayOrigin, pubkey), + ); + if (!raw) return EMPTY_THREAD_PIN_STORE; + return parseThreadPinPayload(JSON.parse(raw)) ?? EMPTY_THREAD_PIN_STORE; + } catch { + return EMPTY_THREAD_PIN_STORE; + } +} + +/** Best-effort mirror; the caller's in-memory state stays authoritative. */ +export function writeThreadPinStore( + relayOrigin: string, + pubkey: string, + store: ThreadPinStore, +): void { + try { + globalThis.localStorage?.setItem( + threadPinsStorageKey(relayOrigin, pubkey), + JSON.stringify(store), + ); + } catch { + // The pin still applies for this session and syncs via the relay. + } +} diff --git a/desktop/src/features/threads/lib/threadPinsSync.test.mjs b/desktop/src/features/threads/lib/threadPinsSync.test.mjs new file mode 100644 index 00000000000..5d26f4b0ce3 --- /dev/null +++ b/desktop/src/features/threads/lib/threadPinsSync.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { + installFakeWindow, + installTauriMock, + makeFakeWindow, +} from "../../sidebar/lib/sidebarSyncTestHelpers.mjs"; +import { ThreadPinsSyncManager } from "./threadPinsSync.ts"; + +const RELAY = "wss://pins.test"; +const A = "a".repeat(64); +const B = "b".repeat(64); + +function store(roots = {}) { + return { version: 1, roots }; +} + +function ownEvent(createdAt) { + return { + id: `e${createdAt}`, + pubkey: "pubkey", + content: "remote-cipher", + created_at: createdAt, + kind: 30078, + tags: [["d", "thread-pins"]], + sig: "s", + }; +} + +async function flush() { + for (let i = 0; i < 10; i++) await new Promise((r) => setImmediate(r)); +} + +test("first sync seeds non-empty local pins when remote is absent", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + const restore = installFakeWindow(makeFakeWindow()); + try { + const manager = new ThreadPinsSyncManager("pubkey", RELAY); + const result = await manager.bootstrap( + store({ [A]: { pinned: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.notEqual(manager.getPendingStore(), null); + manager.destroy(); + } finally { + restore(); + mock.reset(); + } +}); + +test("a failed fetch never seeds local pins over an unseen remote", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("offline")), + ); + const restore = installFakeWindow(makeFakeWindow()); + try { + const manager = new ThreadPinsSyncManager("pubkey", RELAY); + const result = await manager.bootstrap( + store({ [A]: { pinned: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); + manager.destroy(); + } finally { + restore(); + mock.reset(); + } +}); + +test("bootstrap hands back the remote store when one exists", async () => { + const remote = store({ [B]: { pinned: true, updatedAt: 9 } }); + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ownEvent(50)]), + ); + const restore = installFakeWindow(makeFakeWindow()); + const tauri = installTauriMock(JSON.stringify(remote)); + try { + const manager = new ThreadPinsSyncManager("pubkey", RELAY); + const result = await manager.bootstrap(store()); + assert.equal(result.action, "apply-remote"); + assert.deepEqual(result.data.store, remote); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +test("publish merges the latest remote blob first, so another device's unpin survives", async () => { + // Device two unpinned A at t=20; this device still holds its t=10 pin and + // pins B. The published blob must keep A unpinned. + const remote = store({ [A]: { pinned: false, updatedAt: 20 } }); + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ownEvent(60)]), + ); + const published = []; + mock.method(relayClient, "publishEvent", (event) => { + published.push(event); + return Promise.resolve(); + }); + const fakeWindow = makeFakeWindow(); + const restore = installFakeWindow(fakeWindow); + const tauri = installTauriMock(JSON.stringify(remote)); + try { + const manager = new ThreadPinsSyncManager("pubkey", RELAY); + manager.publishPins( + store({ + [A]: { pinned: true, updatedAt: 10 }, + [B]: { pinned: true, updatedAt: 30 }, + }), + ); + fakeWindow._fireTimer(); + await flush(); + + assert.equal(published.length, 1); + assert.ok( + published[0].created_at > 60, + "created_at passes the remote head", + ); + assert.deepEqual(published[0].tags, [ + ["d", "thread-pins"], + ["t", "thread-pins"], + ]); + assert.deepEqual( + JSON.parse(tauri.capturedPlaintext()), + store({ + [A]: { pinned: false, updatedAt: 20 }, + [B]: { pinned: true, updatedAt: 30 }, + }), + ); + assert.equal(manager.getPendingStore(), null); + manager.destroy(); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +test("destroy cancels a pending publish", () => { + const fakeWindow = makeFakeWindow(); + const restore = installFakeWindow(fakeWindow); + try { + const manager = new ThreadPinsSyncManager("pubkey", RELAY); + manager.publishPins(store({ [A]: { pinned: true, updatedAt: 1 } })); + manager.destroy(); + assert.equal(manager.getPendingStore(), null); + assert.equal(fakeWindow._hasTimer(), false); + } finally { + restore(); + } +}); diff --git a/desktop/src/features/threads/lib/threadPinsSync.ts b/desktop/src/features/threads/lib/threadPinsSync.ts new file mode 100644 index 00000000000..08954c7412b --- /dev/null +++ b/desktop/src/features/threads/lib/threadPinsSync.ts @@ -0,0 +1,213 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { + nip44DecryptFromSelf, + nip44EncryptToSelf, + signRelayEvent, +} from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_THREAD_PINS } from "@/shared/constants/kinds"; +import { + advanceWatermark, + readWatermark, + runBootstrap, + type FetchResult, +} from "@/features/sidebar/lib/sidebarSyncWatermark"; +import { + mergeThreadPinStores, + parseThreadPinPayload, + threadPinStoresEqual, + type ThreadPinStore, +} from "./threadPins"; + +const D_TAG = "thread-pins"; +const BLOB_TYPE = D_TAG; +const DEBOUNCE_MS = 2_000; + +export type RemoteThreadPins = { + store: ThreadPinStore; + createdAt: number; + eventId: string; +}; + +async function decryptAndParse( + event: RelayEvent, +): Promise<RemoteThreadPins | null> { + try { + const plaintext = await nip44DecryptFromSelf(event.content); + const store = parseThreadPinPayload(JSON.parse(plaintext)); + if (!store) return null; + return { store, createdAt: event.created_at, eventId: event.id }; + } catch { + return null; + } +} + +/** Syncs the user's pinned threads across devices; mirrors the project sidebar membership manager. */ +export class ThreadPinsSyncManager { + private pubkey: string; + private relayUrl: string; + private debounceTimer: number | null = null; + private lastRemoteCreatedAt: number; + private pendingStore: ThreadPinStore | null = null; + private lastPublishedStore: ThreadPinStore | null = null; + private destroyed = false; + + constructor(pubkey: string, relayUrl: string) { + this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); + } + + private fetchOwnEvents() { + return relayClient.fetchEvents({ + kinds: [KIND_THREAD_PINS], + authors: [this.pubkey], + "#d": [D_TAG], + limit: 1, + }); + } + + async fetchRemotePins(): Promise<FetchResult<RemoteThreadPins>> { + try { + const events = await this.fetchOwnEvents(); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; + } + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; + } catch { + return { status: "failed" }; + } + } + + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; + } + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); + } + + cancelPendingPublish(): void { + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + } + + getPendingStore(): ThreadPinStore | null { + return this.pendingStore; + } + + publishPins(store: ThreadPinStore): void { + this.pendingStore = store; + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + } + this.debounceTimer = window.setTimeout(() => { + this.debounceTimer = null; + void this.doPublish(store); + }, DEBOUNCE_MS); + } + + private async mergeWithRemoteBeforePublish( + store: ThreadPinStore, + ): Promise<ThreadPinStore> { + try { + const events = await this.fetchOwnEvents(); + if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; + const event = events[0]; + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); + if (!remote) return store; + return mergeThreadPinStores(store, remote.store); + } catch { + return store; + } + } + + private async doPublish(store: ThreadPinStore): Promise<void> { + try { + const merged = await this.mergeWithRemoteBeforePublish(store); + if (this.destroyed) return; + if ( + this.lastPublishedStore !== null && + threadPinStoresEqual(this.lastPublishedStore, merged) + ) { + this.pendingStore = null; + return; + } + const ciphertext = await nip44EncryptToSelf(JSON.stringify(merged)); + const createdAt = Math.max( + Math.floor(Date.now() / 1_000), + this.lastRemoteCreatedAt + 1, + ); + const event = await signRelayEvent({ + kind: KIND_THREAD_PINS, + content: ciphertext, + createdAt, + tags: [ + ["d", D_TAG], + ["t", D_TAG], + ], + }); + if (this.destroyed) return; + await relayClient.publishEvent( + event, + "Timed out publishing pinned threads.", + "Failed to publish pinned threads.", + ); + this.recordRemoteHead(event.created_at); + this.lastPublishedStore = merged; + this.pendingStore = null; + } catch (error) { + console.warn("[threadPinsSync] publish failed:", error); + } + } + + async subscribe( + onUpdate: (remote: RemoteThreadPins) => void, + ): Promise<() => Promise<void>> { + return relayClient.subscribeLive( + { + kinds: [KIND_THREAD_PINS], + authors: [this.pubkey], + "#d": [D_TAG], + limit: 0, + }, + (event: RelayEvent) => { + if (event.pubkey !== this.pubkey) return; + this.recordRemoteHead(event.created_at); + void decryptAndParse(event).then((result) => { + if (result) onUpdate(result); + }); + }, + ); + } + + async bootstrap(localStore: ThreadPinStore) { + const fetchResult = await this.fetchRemotePins(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (store) => Object.keys(store.roots).length > 0, + publishFn: (store) => this.publishPins(store), + }); + } + + destroy(): void { + this.destroyed = true; + this.cancelPendingPublish(); + this.pendingStore = null; + } +} 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..651c5dba315 --- /dev/null +++ b/desktop/src/features/threads/lib/threadTitles.test.mjs @@ -0,0 +1,265 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applyRootTitleWrite, + applyTitleWrite, + batchTitleSyncChannels, + countUnreadByTitledRoot, + createThreadTitleBatcher, + findThreadTitle, + interpretTitleDraft, + MAX_LIVE_SYNC_CHANNELS_PER_FILTER, + summarizeWorkingTitledThreads, + THREAD_TITLES_QUERY_KEY, + threadTitleRootQueryKey, +} from "./threadTitles.ts"; + +const CHANNEL = "634a9a88-e929-427c-9dc7-3b7f7194e882"; +const OTHER_CHANNEL = "11111111-2222-4333-8444-555555555555"; +const ROOT = "a".repeat(64); +const OTHER_ROOT = "b".repeat(64); + +function entry(overrides = {}) { + return { + author: "p".repeat(64), + channelId: CHANNEL, + lastActivityAt: 100, + revision: "r".repeat(64), + rootId: ROOT, + title: "Release checklist", + updatedAt: 100, + ...overrides, + }; +} + +test("findThreadTitle matches channel and root, case-insensitively on the root", () => { + const titles = [entry(), entry({ rootId: OTHER_ROOT, title: "Other" })]; + assert.equal( + findThreadTitle(titles, CHANNEL, ROOT.toUpperCase())?.title, + "Release checklist", + ); + // The same root id in another channel is a different thread. + assert.equal(findThreadTitle(titles, OTHER_CHANNEL, ROOT), null); + assert.equal(findThreadTitle(undefined, CHANNEL, ROOT), null); + assert.equal(findThreadTitle(titles, null, ROOT), null); +}); + +/** 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(), lastActivityAt: undefined }]; + }); + 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(), lastActivityAt: undefined }; + 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("applyTitleWrite moves a renamed thread to the top and drops a cleared one", () => { + const titles = [ + entry({ rootId: OTHER_ROOT, title: "Newer", lastActivityAt: 500 }), + entry(), + ]; + const renamed = applyTitleWrite( + titles, + { channelId: CHANNEL, rootId: ROOT, title: "Renamed" }, + 900, + ); + assert.deepEqual( + renamed.map((t) => [t.rootId, t.title, t.lastActivityAt]), + [ + [ROOT, "Renamed", 900], + [OTHER_ROOT, "Newer", 500], + ], + ); + + const cleared = applyTitleWrite( + titles, + { channelId: CHANNEL, rootId: ROOT.toUpperCase(), title: null }, + 900, + ); + assert.deepEqual( + cleared.map((t) => t.rootId), + [OTHER_ROOT], + ); +}); + +test("countUnreadByTitledRoot counts unread replies of titled threads only", () => { + const titles = [entry(), entry({ rootId: OTHER_ROOT, title: "Other" })]; + const counts = countUnreadByTitledRoot(titles, [ + { id: "r1", rootId: ROOT }, + { id: "r2", rootId: ROOT.toUpperCase() }, + // The same reply surfaced twice (live activity and a reopened Inbox row). + { id: "r2", rootId: ROOT }, + // Untitled thread and a reply with no resolvable root are ignored. + { id: "r3", rootId: "c".repeat(64) }, + { id: "r4", rootId: null }, + ]); + assert.deepEqual([...counts], [[ROOT, 2]]); + // A titled thread with nothing unread has no entry rather than a zero. + assert.equal(counts.has(OTHER_ROOT), false); +}); + +test("countUnreadByTitledRoot is empty before titles load", () => { + assert.equal( + countUnreadByTitledRoot(undefined, [{ id: "r1", rootId: ROOT }]).size, + 0, + ); +}); + +test("summarizeWorkingTitledThreads counts titled threads and keeps the earliest start", () => { + const titles = [entry(), entry({ rootId: OTHER_ROOT, title: "Other" })]; + const summary = summarizeWorkingTitledThreads( + titles, + new Map([ + [ROOT, { anchorAt: 2_000 }], + [OTHER_ROOT.toUpperCase(), { anchorAt: 1_000 }], + // Untitled: not in the Threads list, so it neither counts nor anchors. + ["c".repeat(64), { anchorAt: 500 }], + ]), + ); + assert.deepEqual(summary, { anchorAt: 1_000, threadCount: 2 }); +}); + +test("summarizeWorkingTitledThreads is null when no titled thread is working", () => { + const working = new Map([["c".repeat(64), { anchorAt: 500 }]]); + assert.equal(summarizeWorkingTitledThreads([entry()], working), null); + assert.equal(summarizeWorkingTitledThreads([entry()], new Map()), null); + // Before titles load there is nothing to point at yet. + assert.equal( + summarizeWorkingTitledThreads( + undefined, + new Map([[ROOT, { anchorAt: 1 }]]), + ), + null, + ); +}); + +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..e028949f76a --- /dev/null +++ b/desktop/src/features/threads/lib/threadTitles.ts @@ -0,0 +1,258 @@ +import type { + ThreadTitle, + 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 }]); + }); + }; +} + +/** The shared title for a thread, if one is set. */ +export function findThreadTitle( + titles: readonly ThreadTitle[] | undefined, + channelId: string | null, + rootId: string | null, +): ThreadTitle | null { + if (!titles || !channelId || !rootId) { + return null; + } + const root = rootId.toLowerCase(); + return ( + titles.find( + (entry) => entry.channelId === channelId && entry.rootId === root, + ) ?? null + ); +} + +/** 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 the cached list so the UI updates before the refetch. */ +export function applyTitleWrite( + titles: readonly ThreadTitle[] | 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, +): ThreadTitle[] { + const root = write.rootId.toLowerCase(); + const rest = (titles ?? []).filter( + (entry) => !(entry.channelId === write.channelId && entry.rootId === root), + ); + if (write.title === null) { + return rest; + } + const previous = findThreadTitle(titles, write.channelId, root); + return [ + { + author: previous?.author ?? "", + channelId: write.channelId, + lastActivityAt: Math.max(previous?.lastActivityAt ?? 0, nowSeconds), + revision: write.revision ?? previous?.revision ?? "", + rootId: root, + title: write.title, + updatedAt: nowSeconds, + }, + ...rest, + ]; +} + +/** 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, + }; +} + +/** + * Unread reply counts per thread root, for the titled threads only. Replies + * are counted once each, so a reply listed twice (live activity plus a + * reopened Inbox row) can't inflate a count. + */ +export function countUnreadByTitledRoot( + titles: readonly ThreadTitle[] | undefined, + unreadReplies: ReadonlyArray<{ id: string; rootId: string | null }>, +): Map<string, number> { + const titledRoots = new Set((titles ?? []).map((entry) => entry.rootId)); + const seen = new Set<string>(); + const counts = new Map<string, number>(); + for (const reply of unreadReplies) { + const root = reply.rootId?.toLowerCase(); + if (!root || !titledRoots.has(root) || seen.has(reply.id)) { + continue; + } + seen.add(reply.id); + counts.set(root, (counts.get(root) ?? 0) + 1); + } + return counts; +} + +/** Agent work across the threads the Threads view lists. */ +export type WorkingTitledThreads = { + /** Start of the longest-running turn, on the desktop clock. */ + anchorAt: number; + threadCount: number; +}; + +/** + * Rolls active thread turns up for the sidebar's Threads entry. Untitled + * threads are left out: the view doesn't list them, so counting them would + * light the entry with no row to find. + */ +export function summarizeWorkingTitledThreads( + titles: readonly ThreadTitle[] | undefined, + workingThreads: ReadonlyMap<string, { anchorAt: number }>, +): WorkingTitledThreads | null { + if (!titles || workingThreads.size === 0) return null; + const titledRoots = new Set(titles.map((entry) => entry.rootId)); + let anchorAt = Number.POSITIVE_INFINITY; + let threadCount = 0; + for (const [rootId, summary] of workingThreads) { + if (!titledRoots.has(rootId.toLowerCase())) continue; + threadCount += 1; + anchorAt = Math.min(anchorAt, summary.anchorAt); + } + return threadCount === 0 ? null : { anchorAt, threadCount }; +} + +/** 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/lib/useThreadPins.ts b/desktop/src/features/threads/lib/useThreadPins.ts new file mode 100644 index 00000000000..c7690b40742 --- /dev/null +++ b/desktop/src/features/threads/lib/useThreadPins.ts @@ -0,0 +1,144 @@ +import * as React from "react"; + +import { relayClient } from "@/shared/api/relayClient"; +import { + EMPTY_THREAD_PIN_STORE, + mergeThreadPinStores, + pinnedRoots, + readThreadPinStore, + threadPinStoresEqual, + togglePin, + writeThreadPinStore, + type ThreadPinStore, +} from "./threadPins"; +import { ThreadPinsSyncManager, type RemoteThreadPins } from "./threadPinsSync"; + +/** + * The current identity's pinned thread roots, synced across devices, plus a + * toggle. Toggling is unavailable (undefined) until the relay and identity + * resolve. + */ +export function useThreadPins( + relayUrl: string | null, + pubkey: string | undefined, +): { + pinned: ReadonlySet<string>; + toggle: ((rootId: string) => void) | undefined; +} { + const [store, setStore] = React.useState<ThreadPinStore>( + EMPTY_THREAD_PIN_STORE, + ); + const storeRef = React.useRef(store); + storeRef.current = store; + const managerRef = React.useRef<ThreadPinsSyncManager | null>(null); + const lastAppliedRemoteTs = React.useRef(0); + const lastAppliedEventId = React.useRef(""); + + React.useEffect(() => { + lastAppliedRemoteTs.current = 0; + lastAppliedEventId.current = ""; + if (!relayUrl || !pubkey) { + setStore(EMPTY_THREAD_PIN_STORE); + return; + } + setStore(readThreadPinStore(relayUrl, pubkey)); + managerRef.current = new ThreadPinsSyncManager(pubkey, relayUrl); + return () => { + managerRef.current?.destroy(); + managerRef.current = null; + }; + }, [pubkey, relayUrl]); + + const applyRemote = React.useCallback( + (remote: RemoteThreadPins) => + (current: ThreadPinStore): ThreadPinStore => { + if (!relayUrl || !pubkey) return current; + if (remote.createdAt < lastAppliedRemoteTs.current) return current; + if ( + remote.createdAt === lastAppliedRemoteTs.current && + remote.eventId <= lastAppliedEventId.current + ) { + return current; + } + lastAppliedRemoteTs.current = remote.createdAt; + lastAppliedEventId.current = remote.eventId; + managerRef.current?.cancelPendingPublish(); + const merged = mergeThreadPinStores(current, remote.store); + writeThreadPinStore(relayUrl, pubkey, merged); + if (!threadPinStoresEqual(merged, remote.store)) { + managerRef.current?.publishPins(merged); + } + return merged; + }, + [pubkey, relayUrl], + ); + + React.useEffect(() => { + if (!relayUrl || !pubkey) return; + let cancelled = false; + void managerRef.current + ?.bootstrap(readThreadPinStore(relayUrl, pubkey)) + .then((result) => { + if (!cancelled && result.action === "apply-remote") { + setStore(applyRemote(result.data)); + } + }); + return () => { + cancelled = true; + }; + }, [applyRemote, pubkey, relayUrl]); + + React.useEffect(() => { + if (!relayUrl || !pubkey) return; + let unsubscribe: (() => Promise<void>) | null = null; + let cancelled = false; + void managerRef.current + ?.subscribe((remote) => { + if (!cancelled) setStore(applyRemote(remote)); + }) + .then((dispose) => { + if (cancelled) { + void dispose(); + } else { + unsubscribe = dispose; + } + }); + return () => { + cancelled = true; + if (unsubscribe) void unsubscribe(); + }; + }, [applyRemote, pubkey, relayUrl]); + + React.useEffect(() => { + if (!relayUrl || !pubkey) return; + let cancelled = false; + const unsubscribe = relayClient.subscribeToReconnects(() => { + void managerRef.current?.fetchRemotePins().then((result) => { + if (cancelled) return; + if (result.status === "found") setStore(applyRemote(result.data)); + const pending = managerRef.current?.getPendingStore(); + if (pending) managerRef.current?.publishPins(pending); + }); + }); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [applyRemote, pubkey, relayUrl]); + + const toggle = React.useMemo(() => { + if (!relayUrl || !pubkey) return undefined; + return (rootId: string) => { + // Computed from the ref, not inside a state updater, so the write and + // publish run exactly once even under StrictMode's double-invoke. + const next = togglePin(storeRef.current, rootId, Date.now()); + storeRef.current = next; + setStore(next); + writeThreadPinStore(relayUrl, pubkey, next); + managerRef.current?.publishPins(next); + }; + }, [pubkey, relayUrl]); + + const pinned = React.useMemo(() => pinnedRoots(store), [store]); + return { pinned, toggle }; +} 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/features/threads/ui/ThreadsChannelFilterMenu.tsx b/desktop/src/features/threads/ui/ThreadsChannelFilterMenu.tsx new file mode 100644 index 00000000000..bddc59c3290 --- /dev/null +++ b/desktop/src/features/threads/ui/ThreadsChannelFilterMenu.tsx @@ -0,0 +1,98 @@ +import { ChevronDown } from "lucide-react"; + +import type { ThreadChannelOption } from "@/features/threads/lib/threadChannelFilter"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +const ALL_CHANNELS = "__all__"; + +const TRIGGER_CLASS = + "inline-flex h-8 min-w-0 max-w-64 shrink items-center justify-center gap-1 rounded-lg px-2 text-sm font-medium text-foreground transition-colors hover:bg-muted/70 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:bg-muted/70 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0"; + +/** Narrows the Threads view to one channel; lists only channels with named threads. */ +export function ThreadsChannelFilterMenu({ + onChange, + options, + selected, + totalCount, +}: { + onChange: (channelId: string | null) => void; + options: readonly ThreadChannelOption[]; + selected: string | null; + totalCount: number; +}) { + const active = options.find((option) => option.channelId === selected); + const label = active?.label ?? "All channels"; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <button + aria-label={`Filter threads by channel: ${label}`} + className={TRIGGER_CLASS} + data-testid="threads-channel-filter" + type="button" + > + <span className="min-w-0 truncate">{label}</span> + <ChevronDown className="text-muted-foreground" /> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="end" + className="max-h-96 w-64 overflow-y-auto" + > + <DropdownMenuRadioGroup + onValueChange={(value) => + onChange(value === ALL_CHANNELS ? null : value) + } + value={selected ?? ALL_CHANNELS} + > + <FilterOption + count={totalCount} + label="All channels" + value={ALL_CHANNELS} + /> + {options.length > 0 ? ( + <DropdownMenuSeparator className="my-1 bg-border/60" /> + ) : null} + {options.map((option) => ( + <FilterOption + count={option.count} + key={option.channelId} + label={option.label} + value={option.channelId} + /> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuContent> + </DropdownMenu> + ); +} + +function FilterOption({ + count, + label, + value, +}: { + count: number; + label: string; + value: string; +}) { + return ( + <DropdownMenuRadioItem + data-testid="threads-channel-filter-option" + value={value} + > + <span className="flex min-w-0 flex-1 items-center gap-2"> + <span className="min-w-0 flex-1 truncate">{label}</span> + <span className="shrink-0 text-xs text-muted-foreground">{count}</span> + </span> + </DropdownMenuRadioItem> + ); +} diff --git a/desktop/src/features/threads/ui/ThreadsScreen.tsx b/desktop/src/features/threads/ui/ThreadsScreen.tsx new file mode 100644 index 00000000000..b6ea99824de --- /dev/null +++ b/desktop/src/features/threads/ui/ThreadsScreen.tsx @@ -0,0 +1,565 @@ +import { MessagesSquare, Pin } from "lucide-react"; +import { AnimatePresence } from "motion/react"; +import { useLocation } from "@tanstack/react-router"; +import * as React from "react"; + +import { useAppShell } from "@/app/AppShellContext"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { + type ActiveThreadTurnSummary, + useActiveAgentTurnsByThread, +} from "@/features/agents/activeAgentTurnsStore"; +import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useThreadViewMode } from "@/features/channels/lib/threadViewModePreference"; +import { + mergeOpenChannelDirectory, + useOpenChannelDirectoryQuery, +} from "@/features/channels/openChannelDirectory"; +import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence"; +import { formatRelativeTime } from "@/features/forum/lib/time"; +import { getThreadReference } from "@/features/messages/lib/threading"; +import type { TimelineMessage } from "@/features/messages/types"; +import { + type ConversationUnread, + ProjectConversationPanel, +} from "@/features/projects/ui/ProjectConversationPanel"; +import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels"; +import { formatWorkingTooltip } from "@/features/sidebar/ui/SidebarSection"; +import { + useMarkOpenThreadRead, + useThreadTitlesQuery, + useTitledThreadUnreadCounts, +} from "@/features/threads/hooks"; +import { + effectiveChannelFilter, + filterByChannel, + threadChannelOptions, +} from "@/features/threads/lib/threadChannelFilter"; +import { unreadReplyIdsForRoot } from "@/features/threads/lib/threadPaneUnread"; +import { partitionByPin } from "@/features/threads/lib/threadPins"; +import { useThreadPins } from "@/features/threads/lib/useThreadPins"; +import { ThreadsChannelFilterMenu } from "@/features/threads/ui/ThreadsChannelFilterMenu"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; +import type { ThreadTitle } from "@/shared/api/tauriThreadTitles"; +import type { Channel } from "@/shared/api/types"; +import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; +import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; +import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; +import { cn } from "@/shared/lib/cn"; +import { useNow } from "@/shared/lib/useNow"; +import { Button } from "@/shared/ui/button"; +import { Skeleton } from "@/shared/ui/skeleton"; + +const CHANNEL_FILTER_SESSION_KEY = "buzz.desktop.threads-channel-filter"; + +const FOCUS_DRAWER = { backLabel: "Back to Threads" }; + +type OpenThread = { + channelId: string; + channelName: string; + rootId: string; + /** Replies unread as the thread opened, before the pane reads them. */ + unreadAtOpen: ReadonlySet<string>; +}; + +function readSessionFilter(): string | null { + try { + return window.sessionStorage.getItem(CHANNEL_FILTER_SESSION_KEY); + } catch { + return null; + } +} + +function writeSessionFilter(channelId: string | null) { + try { + if (channelId === null) { + window.sessionStorage.removeItem(CHANNEL_FILTER_SESSION_KEY); + } else { + window.sessionStorage.setItem(CHANNEL_FILTER_SESSION_KEY, channelId); + } + } catch { + // Storage can be unavailable; the filter still works for this visit. + } +} + +function channelLabel( + channel: Channel | undefined, + currentPubkey: string | undefined, +): string { + if (!channel) { + return "Unknown channel"; + } + const label = resolveChannelDisplayLabel(channel, currentPubkey, undefined); + return channel.channelType === "dm" ? label : `#${label}`; +} + +/** Live agent-working timer, the thread-scoped twin of the sidebar's badge. */ +function ThreadWorkingBadge({ summary }: { summary: ActiveThreadTurnSummary }) { + const now = useNow(1000); + const elapsed = formatElapsed(now - summary.anchorAt); + return ( + <span + className="inline-flex shrink-0 rounded-full bg-primary/10 px-1.5 py-0.5 text-2xs font-medium leading-none tabular-nums text-primary motion-safe:animate-pulse" + data-testid="threads-view-row-working" + title={formatWorkingTooltip(summary)} + > + {summary.agentCount > 1 ? `${elapsed} (${summary.agentCount})` : elapsed} + </span> + ); +} + +function ThreadRow({ + channel, + currentPubkey, + entry, + onOpen, + onTogglePin, + pinned, + selected, + unreadCount, + working, +}: { + channel: Channel | undefined; + currentPubkey: string | undefined; + entry: ThreadTitle; + onOpen: (entry: ThreadTitle, channel: Channel | undefined) => void; + onTogglePin: ((rootId: string) => void) | undefined; + pinned: boolean; + selected: boolean; + unreadCount: number; + working: ActiveThreadTurnSummary | undefined; +}) { + const hasUnread = unreadCount > 0; + return ( + <div + className={cn( + "group/thread-row relative flex min-w-0 items-center rounded-lg hover:bg-accent focus-within:bg-accent", + selected && "bg-accent", + )} + > + <button + aria-current={selected ? "true" : undefined} + className="flex min-w-0 flex-1 flex-col items-start gap-0.5 rounded-lg px-3 py-2 text-left focus-visible:outline-none" + data-pinned={pinned ? "true" : undefined} + data-selected={selected ? "true" : undefined} + data-testid="threads-view-row" + data-unread={hasUnread ? "true" : undefined} + onClick={() => onOpen(entry, channel)} + type="button" + > + <span className="flex w-full min-w-0 items-center gap-2"> + <span + className={cn( + "min-w-0 flex-1 truncate text-sm", + hasUnread + ? "font-bold text-foreground" + : "font-normal text-foreground/80", + )} + > + {entry.title} + </span> + {working ? <ThreadWorkingBadge summary={working} /> : null} + {hasUnread ? ( + <span + className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-primary" + data-testid="threads-view-row-unread" + > + <span + aria-hidden="true" + className="h-2 w-2 rounded-full bg-primary" + /> + {unreadCount} new + </span> + ) : null} + </span> + <span className="flex w-full min-w-0 items-center gap-1.5 text-xs text-muted-foreground"> + <span className="min-w-0 truncate"> + {channelLabel(channel, currentPubkey)} + </span> + <span aria-hidden="true">·</span> + <span className="shrink-0"> + {formatRelativeTime(entry.lastActivityAt)} + </span> + </span> + </button> + {onTogglePin ? ( + <button + aria-label={pinned ? "Unpin thread" : "Pin thread"} + aria-pressed={pinned} + className={cn( + "mr-2 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-background hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + pinned + ? "text-primary" + : "opacity-0 group-hover/thread-row:opacity-100 group-focus-within/thread-row:opacity-100", + )} + data-testid="threads-view-row-pin" + onClick={() => onTogglePin(entry.rootId)} + title={pinned ? "Unpin thread" : "Pin thread"} + type="button" + > + <Pin className={cn("h-3.5 w-3.5", pinned && "fill-current")} /> + </button> + ) : null} + </div> + ); +} + +function ThreadsBody({ + channelFilter, + channelsById, + currentPubkey, + onOpen, + selectedRootId, + titlesQuery, +}: { + channelFilter: string | null; + channelsById: ReadonlyMap<string, Channel>; + currentPubkey: string | undefined; + onOpen: (entry: ThreadTitle, channel: Channel | undefined) => void; + selectedRootId: string | null; + titlesQuery: ReturnType<typeof useThreadTitlesQuery>; +}) { + const unreadCounts = useTitledThreadUnreadCounts(); + const workingThreads = useActiveAgentTurnsByThread(); + const relayOrigin = useRelayOrigin(); + const { pinned: pinnedRoots, toggle: handleTogglePin } = useThreadPins( + relayOrigin, + currentPubkey, + ); + + if (titlesQuery.isPending) { + return ( + <div className="space-y-3 px-3 py-2"> + {[1, 2, 3, 4].map((i) => ( + <div className="space-y-1.5" key={i}> + <Skeleton className="h-4 w-64" /> + <Skeleton className="h-3 w-32" /> + </div> + ))} + </div> + ); + } + + if (titlesQuery.isError) { + return ( + <div className="flex flex-col items-center gap-3 px-4 py-12 text-center"> + <p className="text-sm text-muted-foreground"> + Couldn't load thread titles. + </p> + <Button + onClick={() => void titlesQuery.refetch()} + size="sm" + variant="outline" + > + Retry + </Button> + </div> + ); + } + + if (titlesQuery.data.length === 0) { + return ( + <div className="flex flex-col items-center gap-2 px-4 py-12 text-center"> + <MessagesSquare className="h-6 w-6 text-muted-foreground" /> + <p className="text-sm font-medium text-foreground">No named threads</p> + <p className="max-w-sm text-sm text-muted-foreground"> + Open a thread and use the pencil next to its title to name it. Named + threads show up here, most recently active first. + </p> + </div> + ); + } + + const { pinned, unpinned } = partitionByPin( + filterByChannel(titlesQuery.data, channelFilter), + pinnedRoots, + ); + const renderRows = (entries: ThreadTitle[], isPinned: boolean) => + entries.map((entry) => ( + <li key={`${entry.channelId}:${entry.rootId}`}> + <ThreadRow + channel={channelsById.get(entry.channelId)} + currentPubkey={currentPubkey} + entry={entry} + onOpen={onOpen} + onTogglePin={handleTogglePin} + pinned={isPinned} + selected={entry.rootId === selectedRootId} + unreadCount={unreadCounts.get(entry.rootId) ?? 0} + working={workingThreads.get(entry.rootId)} + /> + </li> + )); + + if (pinned.length === 0) { + return ( + <ul aria-label="Named threads" className="flex flex-col"> + {renderRows(unpinned, false)} + </ul> + ); + } + + return ( + <div className="flex flex-col gap-3"> + <section aria-labelledby="threads-view-pinned-heading"> + <h2 + className="px-3 pb-1 text-xs font-medium text-muted-foreground" + id="threads-view-pinned-heading" + > + Pinned + </h2> + <ul + aria-label="Pinned threads" + className="flex flex-col" + data-testid="threads-view-pinned" + > + {renderRows(pinned, true)} + </ul> + </section> + {unpinned.length > 0 ? ( + <section aria-labelledby="threads-view-recent-heading"> + <h2 + className="px-3 pb-1 text-xs font-medium text-muted-foreground" + id="threads-view-recent-heading" + > + Recent + </h2> + <ul aria-label="Named threads" className="flex flex-col"> + {renderRows(unpinned, false)} + </ul> + </section> + ) : null} + </div> + ); +} + +/** + * Named threads across every readable channel, most recently active first. + * Threads in joined chat channels open beside the list so it keeps its place. + */ +export function ThreadsScreen() { + const titlesQuery = useThreadTitlesQuery({ refetchInterval: true }); + const channelsQuery = useChannelsQuery(); + const currentPubkey = useIdentityQuery().data?.pubkey; + const { goChannel, goForumPost } = useAppNavigation(); + const panelWidth = useThreadPanelWidth(); + const [openThread, setOpenThread] = React.useState<OpenThread | null>(null); + const [selectedFilter, setSelectedFilter] = React.useState(readSessionFilter); + const { feedItemState, markMessageRead, unreadThreadFeedItems } = + useAppShell(); + const unreadFeedReplies = React.useMemo( + () => + unreadThreadFeedItems.map((item) => ({ + id: item.id, + rootId: getThreadReference(item.tags).rootId, + })), + [unreadThreadFeedItems], + ); + // After "Mark unread" the open thread stops reading itself, or the replies + // would be read again at once; opening any thread (or closing) resumes it. + const [heldUnreadThread, setHeldUnreadThread] = + React.useState<OpenThread | null>(null); + useMarkOpenThreadRead( + openThread && openThread !== heldUnreadThread ? openThread.rootId : null, + ); + const markOpenThreadUnread = React.useCallback( + (replyIds: string[]) => { + for (const id of replyIds) feedItemState.markUnread(id); + setHeldUnreadThread(openThread); + }, + [feedItemState.markUnread, openThread], + ); + const openRootId = openThread?.rootId ?? null; + const currentUnread = React.useMemo( + () => unreadReplyIdsForRoot(unreadFeedReplies, openRootId), + [openRootId, unreadFeedReplies], + ); + const markOpenRepliesRead = React.useCallback( + (replies: TimelineMessage[]) => { + for (const reply of replies) { + feedItemState.undoUnread(reply.id); + markMessageRead(reply.id, reply.createdAt); + } + // Once nothing marked unread is left, the open thread reads itself again. + if ([...currentUnread].every((id) => replies.some((r) => r.id === id))) { + setHeldUnreadThread(null); + } + }, + [currentUnread, feedItemState.undoUnread, markMessageRead], + ); + const unreadAtOpen = openThread?.unreadAtOpen; + const paneUnread = React.useMemo<ConversationUnread | undefined>( + () => + unreadAtOpen + ? { + atOpen: unreadAtOpen, + current: currentUnread, + onMarkRead: markOpenRepliesRead, + } + : undefined, + [currentUnread, markOpenRepliesRead, unreadAtOpen], + ); + + // Threads follow the channel thread layout preference: Focus opens the + // large drawer over the list, Split keeps the list beside the thread. + const threadViewMode = useThreadViewMode(); + const isOverlay = useIsThreadPanelOverlay(); + const useFocusDrawer = threadViewMode === "focus" && !isOverlay; + const closeThread = React.useCallback(() => setOpenThread(null), []); + const { channelIsCovered: listIsCovered, markExitComplete } = + useFocusDrawerPresence(useFocusDrawer && openThread !== null, closeThread); + + // Clicking Threads in the sidebar again returns to the list. + const threadsResetId = useLocation({ + select: (location) => + (location.state as { threadsResetId?: unknown } | undefined) + ?.threadsResetId, + }); + const lastResetIdRef = React.useRef(threadsResetId); + React.useEffect(() => { + if (threadsResetId === lastResetIdRef.current) return; + lastResetIdRef.current = threadsResetId; + closeThread(); + }, [closeThread, threadsResetId]); + + // Titles also come from open channels the user hasn't joined; only then pay + // for the all-open directory scan (React Query dedups it across surfaces). + const needsDirectory = + channelsQuery.isSuccess && + (titlesQuery.data ?? []).some( + (entry) => + !channelsQuery.data.some((channel) => channel.id === entry.channelId), + ); + const openDirectoryQuery = useOpenChannelDirectoryQuery({ + enabled: needsDirectory, + }); + const channelsById = React.useMemo( + () => + new Map( + mergeOpenChannelDirectory( + channelsQuery.data ?? [], + openDirectoryQuery.data, + ).map((channel) => [channel.id, channel]), + ), + [channelsQuery.data, openDirectoryQuery.data], + ); + const channelOptions = React.useMemo( + () => + threadChannelOptions(titlesQuery.data ?? [], (channelId) => + channelLabel(channelsById.get(channelId), currentPubkey), + ), + [channelsById, currentPubkey, titlesQuery.data], + ); + const channelFilter = titlesQuery.isSuccess + ? effectiveChannelFilter(selectedFilter, channelOptions) + : selectedFilter; + + const handleFilterChange = React.useCallback((channelId: string | null) => { + setSelectedFilter(channelId); + writeSessionFilter(channelId); + }, []); + + const handleOpen = React.useCallback( + (entry: ThreadTitle, channel: Channel | undefined) => { + if (channel?.channelType === "forum") { + void goForumPost(entry.channelId, entry.rootId); + return; + } + // The pane resolves its channel from the joined list; anything else + // (an open channel the user never joined) still navigates. + const joined = channelsQuery.data?.find( + (candidate) => candidate.id === entry.channelId, + ); + if (!joined) { + void goChannel(entry.channelId, { + messageId: entry.rootId, + threadRootId: entry.rootId, + }); + return; + } + setOpenThread({ + channelId: joined.id, + channelName: joined.name, + rootId: entry.rootId, + unreadAtOpen: unreadReplyIdsForRoot(unreadFeedReplies, entry.rootId), + }); + }, + [channelsQuery.data, goChannel, goForumPost, unreadFeedReplies], + ); + + return ( + <div className="relative flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden"> + <div + className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden" + inert={listIsCovered ? true : undefined} + > + <TopChromeInsetHeader flush transparent> + <div className="px-5 py-2"> + <div className="flex min-h-9 items-center gap-2"> + <MessagesSquare className="h-4 w-4 text-muted-foreground" /> + <h1 className="text-sm font-semibold">Threads</h1> + {channelOptions.length > 1 ? ( + <div className="ml-auto flex min-w-0"> + <ThreadsChannelFilterMenu + onChange={handleFilterChange} + options={channelOptions} + selected={channelFilter} + totalCount={titlesQuery.data?.length ?? 0} + /> + </div> + ) : null} + </div> + </div> + </TopChromeInsetHeader> + <p + className="border-b border-border/60 px-5 pb-3 text-sm text-muted-foreground" + data-testid="threads-view-subheader" + > + Named threads, most recent activity first. Name any thread from its + header. + </p> + <div + className="min-h-0 flex-1 overflow-y-auto" + data-testid="threads-view" + > + <div className="w-full max-w-3xl px-2 pb-10 pt-2"> + <ThreadsBody + channelFilter={channelFilter} + channelsById={channelsById} + currentPubkey={currentPubkey} + onOpen={handleOpen} + selectedRootId={openThread?.rootId ?? null} + titlesQuery={titlesQuery} + /> + </div> + </div> + </div> + <AnimatePresence onExitComplete={markExitComplete}> + {openThread ? ( + <ProjectConversationPanel + canResetWidth={panelWidth.canReset} + focusDrawer={useFocusDrawer ? FOCUS_DRAWER : undefined} + hit={{ + channelId: openThread.channelId, + channelName: openThread.channelName, + eventId: openThread.rootId, + threadRootId: openThread.rootId, + }} + key="threads-conversation" + onClose={closeThread} + onMarkUnread={markOpenThreadUnread} + onResetWidth={panelWidth.onResetWidth} + onResizeStart={panelWidth.onResizeStart} + // Switching layouts swaps the drawer out without an exit animation. + onThreadViewModeChange={markExitComplete} + titleOpensThread + unread={paneUnread} + widthPx={panelWidth.widthPx} + /> + ) : null} + </AnimatePresence> + </div> + ); +} diff --git a/desktop/src/features/threads/ui/threadsPanePropParity.test.mjs b/desktop/src/features/threads/ui/threadsPanePropParity.test.mjs new file mode 100644 index 00000000000..e8727dddd59 --- /dev/null +++ b/desktop/src/features/threads/ui/threadsPanePropParity.test.mjs @@ -0,0 +1,179 @@ +/** + * Threads pane ↔ channel thread panel parity guard. + * + * The Threads view shows threads through ProjectConversationPanel, which + * renders the same MessageThreadPanel the channel uses. Each panel feature is + * a prop the surrounding screen has to pass, so a prop added upstream silently + * stays missing from the Threads pane. This test lists every MessageThreadPanel + * prop and fails until each one is either passed by ProjectConversationPanel + * or recorded below as deliberately left out, with the reason. + */ + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import ts from "typescript"; + +/** Props the Threads pane deliberately does not pass. */ +const DELIBERATELY_OMITTED = { + autoSendDraftKey: + "Send-from-drafts navigates to the channel route, which owns the auto-submit.", + onAutoSubmitComplete: "Pairs with autoSendDraftKey.", + huddleMemberPubkeys: "Huddle transcripts open in their channel only.", + huddleMemberPubkeysPending: "Huddle transcripts open in their channel only.", + isHuddleTranscript: "Huddle transcripts open in their channel only.", + searchMessageId: + "The pane is opened from the Threads list, never from a search hit.", + searchQuery: "Pairs with searchMessageId.", + videoReviewPresentation: "Video review threads open in their channel only.", +}; + +function parse(relativePath) { + const url = new URL(relativePath, import.meta.url); + return ts.createSourceFile( + url.pathname, + readFileSync(url, "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); +} + +function findAll(node, predicate, found = []) { + if (predicate(node)) found.push(node); + // forEachChild stops at the first truthy callback result, so return nothing. + ts.forEachChild(node, (child) => { + findAll(child, predicate, found); + }); + return found; +} + +function typeAlias(source, name) { + const [alias] = findAll( + source, + (node) => ts.isTypeAliasDeclaration(node) && node.name.text === name, + ); + assert.ok(alias, `type ${name} not found in ${source.fileName}`); + return alias; +} + +function memberNames(typeLiteral) { + return typeLiteral.members + .filter((member) => member.name) + .map((member) => member.name.text); +} + +function propertyKeys(node) { + return findAll( + node, + (child) => + (ts.isPropertyAssignment(child) || + ts.isShorthandPropertyAssignment(child)) && + ts.isIdentifier(child.name), + ).map((child) => child.name.text); +} + +const panelSource = parse("../../messages/ui/MessageThreadPanel.tsx"); +const layoutSource = parse("../../channels/lib/threadPanelLayout.ts"); +const paneSource = parse("../../projects/ui/ProjectConversationPanel.tsx"); + +/** Every prop MessageThreadPanel accepts, layout props included. */ +function panelProps() { + const props = new Set(); + const visit = (typeNode) => { + if (ts.isIntersectionTypeNode(typeNode)) { + for (const part of typeNode.types) visit(part); + } else if (ts.isTypeLiteralNode(typeNode)) { + for (const name of memberNames(typeNode)) props.add(name); + } else if ( + ts.isTypeReferenceNode(typeNode) && + typeNode.typeName.getText() === "ThreadPanelLayoutProps" + ) { + visit(typeAlias(layoutSource, "ThreadPanelLayoutProps").type); + } else { + assert.fail(`Unhandled prop type part: ${typeNode.getText()}`); + } + }; + visit(typeAlias(panelSource, "MessageThreadPanelProps").type); + return props; +} + +/** Props the Threads pane's <MessageThreadPanel> receives, spreads included. */ +function paneProps() { + const [element] = findAll( + paneSource, + (node) => + (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && + node.tagName.getText() === "MessageThreadPanel", + ); + assert.ok(element, "ProjectConversationPanel renders no MessageThreadPanel"); + const props = new Set(); + for (const attribute of element.attributes.properties) { + if (ts.isJsxAttribute(attribute)) { + props.add(attribute.name.getText()); + continue; + } + const spread = attribute.expression.getText(); + const [declaration] = findAll( + paneSource, + (node) => + ts.isVariableDeclaration(node) && + node.name.getText() === spread && + node.initializer, + ); + assert.ok(declaration, `cannot resolve spread {...${spread}}`); + for (const key of propertyKeys(declaration.initializer)) props.add(key); + // The focus drawer layout comes from the shared helper's return value. + const callsLayoutHelper = findAll( + declaration.initializer, + (node) => + ts.isCallExpression(node) && + node.expression.getText() === "getThreadPanelLayout", + ).length; + if (callsLayoutHelper) { + const [helper] = findAll( + layoutSource, + (node) => + ts.isFunctionDeclaration(node) && + node.name?.text === "getThreadPanelLayout", + ); + for (const literal of findAll( + helper.body, + ts.isObjectLiteralExpression, + )) { + for (const key of propertyKeys(literal)) props.add(key); + } + } + } + return props; +} + +const allProps = panelProps(); +const wiredProps = paneProps(); + +test("the prop inventory parses (guards against an empty, vacuous pass)", () => { + assert.ok(allProps.size > 40, `only ${allProps.size} props found`); + for (const known of ["firstUnreadReplyId", "onSend", "isFocusMode"]) { + assert.ok(allProps.has(known), `${known} missing from inventory`); + } +}); + +test("every MessageThreadPanel prop is wired in the Threads pane or deliberately omitted", () => { + const unhandled = [...allProps].filter( + (prop) => !wiredProps.has(prop) && !(prop in DELIBERATELY_OMITTED), + ); + assert.deepEqual( + unhandled, + [], + "MessageThreadPanel gained props the Threads pane does not pass. Wire " + + "each in ProjectConversationPanel, or add it to DELIBERATELY_OMITTED " + + "with the reason.", + ); +}); + +test("the omission list has no stale entries", () => { + for (const prop of Object.keys(DELIBERATELY_OMITTED)) { + assert.ok(allProps.has(prop), `${prop} is no longer a panel prop`); + assert.ok(!wiredProps.has(prop), `${prop} is wired now; drop the omission`); + } +}); 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..859c1c17c58 --- /dev/null +++ b/desktop/src/shared/api/tauriThreadTitles.ts @@ -0,0 +1,88 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +/** A shared thread title (NIP-AR artifact, type `buzz.thread-title`). */ +export type ThreadTitle = { + title: string; + channelId: string; + rootId: string; + revision: string; + author: string; + updatedAt: number; + /** Newest of the last reply and the title revision, in unix seconds. */ + lastActivityAt: number; +}; + +type RawThreadTitle = { + title: string; + channel: string; + root: string; + revision: string; + author: string; + updated_at: number; + last_activity_at: number; +}; + +/** Every live thread title the user can read, most recently active first. */ +export async function listThreadTitles(): Promise<ThreadTitle[]> { + const raw = await invokeTauri<RawThreadTitle[]>("list_thread_titles"); + return raw.map((entry) => ({ + title: entry.title, + channelId: entry.channel, + rootId: entry.root, + revision: entry.revision, + author: entry.author, + updatedAt: entry.updated_at, + lastActivityAt: entry.last_activity_at, + })); +} + +/** A thread's live title, looked up by its root (no activity ordering). */ +export type ThreadTitleRecord = Omit<ThreadTitle, "lastActivityAt">; + +type RawThreadTitleRecord = Omit<RawThreadTitle, "last_activity_at">; + +/** 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..39ebdacd35e 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; @@ -44,7 +47,8 @@ export const KIND_HUDDLE_ENDED = 48103; export const KIND_HUDDLE_LIVENESS = 48104; // NIP-78 application-specific data. All use kind 30078; the relay // differentiates them by d-tag ("read-state:<slotId>", "channel-sections", -// "channel-mutes", "channel-stars", "channel-sort", "project-sidebar-membership"). +// "channel-mutes", "channel-stars", "channel-sort", "project-sidebar-membership", +// "thread-pins"). export const KIND_READ_STATE = 30078; export const KIND_CHANNEL_SECTIONS = 30078; export const KIND_CHANNEL_MUTES = 30078; @@ -52,6 +56,7 @@ export const KIND_CHANNEL_STARS = 30078; export const KIND_CHANNEL_SORT = 30078; export const KIND_PROJECT_SIDEBAR_MEMBERSHIP = 30078; export const KIND_COMMUNITY_THEME = 30078; +export const KIND_THREAD_PINS = 30078; // NIP-33 persona/team/managed-agent projection events (d-tag keyed). Published // backend-side as secrets-stripped snapshots; the inbound sync hook subscribes // to all three to patch local records. Mirror of buzz-core's KIND_PERSONA etc. diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e03c85d40d4..d8351ab3e44 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; @@ -1514,6 +1522,7 @@ declare global { channelId: string; turnId: string; kind?: "turn_started" | "turn_completed"; + threadRootEventId?: string; }) => void; __BUZZ_E2E_SEED_OBSERVER_EVENTS__?: (input: { agentPubkey: string; @@ -3408,6 +3417,18 @@ type MockCanvasRevision = { }; let mockCanvasRevisions = new Map<string, MockCanvasRevision[]>(); +type MockThreadTitle = { + title: string; + channel: string; + root: string; + revision: string; + author: string; + updated_at: number; + last_activity_at: number; +}; +/** Keyed by `${channel}:${root}`; mirrors the list/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 +11531,7 @@ export function maybeInstallE2eTauriMocks() { resetMockTeamCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockCanvasRevisions(config); + mockThreadTitles = new Map(); resetMockPendingCommunityDeepLinks(config); resetMockPendingNavigationDeepLinks(config); resetMockPendingEntityDeepLinks(config); @@ -11672,6 +11694,42 @@ 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, + last_activity_at: event.created_at, + }); + emitMockLiveEvent(channel.id, event); + }; window.__BUZZ_E2E_SET_MOCK_USER_STATUS__ = ({ text, emoji, @@ -11975,6 +12033,7 @@ export function maybeInstallE2eTauriMocks() { channelId, turnId, kind = "turn_started", + threadRootEventId, }) => { seedTurnSeq += 1; const event = { @@ -11985,7 +12044,7 @@ export function maybeInstallE2eTauriMocks() { channelId, sessionId: null, turnId, - payload: null, + payload: threadRootEventId ? { threadRootEventId } : null, }; syncAgentTurnsFromEvents(agentPubkey, [event]); syncAgentObserverEvents(agentPubkey, [event]); @@ -14951,6 +15010,67 @@ export function maybeInstallE2eTauriMocks() { mockCanvasRevisions.set(req.channelId, [revision, ...stream]); return { ok: true, event_id: revision.eventId, verified: true }; } + case "list_thread_titles": { + // Mirror the command: activity is the later of the title and the + // newest reply; ties keep newest-revision-first. + return [...mockThreadTitles.values()] + .reverse() + .map((entry) => ({ + ...entry, + last_activity_at: getMockMessageStore(entry.channel).reduce( + (latest, event) => + event.tags.some( + (tag) => tag[0] === "e" && tag[1] === entry.root, + ) + ? Math.max(latest, event.created_at) + : latest, + entry.last_activity_at, + ), + })) + .sort((a, b) => b.last_activity_at - a.last_activity_at); + } + 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)) + .map(({ last_activity_at: _activity, ...entry }) => entry); + } + 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, + last_activity_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..5588ad52ee5 --- /dev/null +++ b/desktop/tests/e2e/thread-titles.spec.ts @@ -0,0 +1,1048 @@ +import { expect, type Page, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } 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_EMIT_MOCK_TYPING__?: (input: { + channelName: string; + pubkey?: string; + threadHeadId?: string; + }) => unknown; + __BUZZ_E2E_SET_REMOTE_THREAD_TITLE__?: (input: { + channelName: string; + rootId: string; + title: string; + }) => void; + __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: { + agentPubkey: string; + channelId: string; + turnId: string; + kind?: "turn_started" | "turn_completed"; + threadRootEventId?: 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; +} + +async function emitReply(page: Page, parentEventId: string, content: string) { + await page.evaluate( + ({ channelName, content, parentEventId, pubkey }) => { + (window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + content, + parentEventId, + pubkey, + }); + }, + { channelName: CHANNEL_NAME, content, parentEventId, pubkey: ALICE_PUBKEY }, + ); +} + +async function openReplyMenu(page: Page, content: string) { + const panel = page.getByTestId("message-thread-panel"); + const replyId = await panel + .locator("[data-message-id]") + .filter({ hasText: content }) + .last() + .getAttribute("data-message-id"); + const toggle = page.getByTestId(`mark-read-toggle-${replyId}`); + // A click while the previous menu is still closing can be swallowed. + await expect(async () => { + await panel.locator(`[data-message-id="${replyId}"]`).hover(); + await page.getByTestId(`more-actions-${replyId}`).click(); + await expect(toggle).toBeVisible({ timeout: 1_000 }); + }).toPass(); + return toggle; +} + +async function nameOpenThread(page: Page, title: string) { + const panel = page.getByTestId("message-thread-panel"); + await panel.getByTestId("thread-title").hover(); + await panel.getByTestId("thread-title-edit").click(); + await panel.getByTestId("thread-title-input").fill(title); + await panel.getByTestId("thread-title-input").press("Enter"); + await expect(panel.getByTestId("thread-title")).toHaveText(title); +} + +/** + * Sets titles as another user would. The title list is cached app-wide and + * only a title event in a live-subscribed channel invalidates it, so the last + * title must be in CHANNEL_NAME; that write is what makes the others show. + */ +async function setRemoteTitles( + page: Page, + titles: { channelName: string; rootId: string; title: string }[], +) { + expect(titles.at(-1)?.channelName).toBe(CHANNEL_NAME); + await page.getByTestId(`channel-${CHANNEL_NAME}`).click(); + await expect + .poll(() => + page.evaluate( + (name) => + ( + window as MockMessageWindow + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: name, + kind: 45010, + }) ?? false, + CHANNEL_NAME, + ), + ) + .toBe(true); + await page.evaluate((titles) => { + for (const title of titles) { + (window as MockMessageWindow).__BUZZ_E2E_SET_REMOTE_THREAD_TITLE__?.( + title, + ); + } + }, titles); +} + +test.describe("thread titles", () => { + test.use({ viewport: { width: 1280, height: 720 } }); + + test("name a thread, find it in the Threads view, and reopen 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", + ); + + await page.getByTestId("open-threads-view").click(); + const rows = page.getByTestId("threads-view-row"); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toContainText("Release checklist"); + await expect(rows.first()).toContainText(`#${CHANNEL_NAME}`); + + // The thread opens beside the list; the Threads view stays put. + await rows.first().click(); + const sidePanel = page.getByTestId("message-thread-panel"); + await expect( + sidePanel.getByTestId("message-thread-open-channel"), + ).toHaveText(`Release checklist · #${CHANNEL_NAME}`); + await expect(page.getByTestId("threads-view")).toBeVisible(); + await expect(rows.first()).toHaveAttribute("data-selected", "true"); + + // Like the Inbox header, the title is a link, not an editor: it opens + // the thread in its channel, where it can still be renamed. + await expect(sidePanel.getByTestId("thread-title-edit")).toHaveCount(0); + await sidePanel.getByRole("button", { name: "Open full thread" }).click(); + await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL_NAME); + await expect(page.getByTestId("threads-view")).toHaveCount(0); + await expect( + page.getByTestId("message-thread-panel").getByTestId("thread-title"), + ).toHaveText("Release checklist"); + await expect( + page.getByTestId("message-thread-panel").getByTestId("thread-title-edit"), + ).toHaveCount(1); + }); + + test("own messages can be edited and deleted from the Threads view", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + const rootId = await openThread(page, "Root of a thread to tidy up."); + await nameOpenThread(page, "Tidy up"); + const [editId, deleteId] = await page.evaluate( + ({ channelName, parentEventId, pubkey }) => + ["My reply before editing.", "My reply to delete."].map( + (content) => + (window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + content, + parentEventId, + pubkey, + })?.id ?? null, + ), + { + channelName: CHANNEL_NAME, + parentEventId: rootId, + pubkey: MOCK_IDENTITY_PUBKEY, + }, + ); + expect(editId).not.toBeNull(); + expect(deleteId).not.toBeNull(); + + await page.getByTestId("open-threads-view").click(); + await page.getByTestId("threads-view-row").first().click(); + const panel = page.getByTestId("message-thread-panel"); + await expect(panel).toContainText("My reply to delete."); + + const openMoreActions = async (messageId: string) => { + await panel.locator(`[data-message-id="${messageId}"]`).hover(); + await page.getByTestId(`more-actions-${messageId}`).click(); + await expect(page.locator('[role="menuitem"]').first()).toBeVisible(); + }; + + // Someone else's reply gets neither action. + const foreignReplyId = await panel + .locator("[data-message-id]") + .filter({ hasText: "First reply." }) + .last() + .getAttribute("data-message-id"); + await openMoreActions(foreignReplyId as string); + await expect( + page.getByTestId(`delete-message-${foreignReplyId}`), + ).toHaveCount(0); + await expect( + page.getByTestId(`edit-message-${foreignReplyId}`), + ).toHaveCount(0); + await page.keyboard.press("Escape"); + + await openMoreActions(editId as string); + await page.getByTestId(`edit-message-${editId}`).click(); + await expect(panel.getByTestId("edit-target")).toBeVisible(); + await panel.getByTestId("message-input").click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.type("My reply after editing."); + await page.keyboard.press("Enter"); + await expect(panel.getByTestId("edit-target")).toBeHidden(); + await expect(panel.locator(`[data-message-id="${editId}"]`)).toContainText( + "My reply after editing.", + ); + + await openMoreActions(deleteId as string); + await page.getByTestId(`delete-message-${deleteId}`).click(); + await page + .getByRole("alertdialog") + .getByRole("button", { name: "Delete" }) + .click(); + await expect(panel).not.toContainText("My reply to delete."); + await expect(page.getByTestId("threads-view")).toBeVisible(); + + // Deleting the root leaves nothing to show, so the pane closes. + await openMoreActions(rootId); + await page.getByTestId(`delete-message-${rootId}`).click(); + await page + .getByRole("alertdialog") + .getByRole("button", { name: "Delete" }) + .click(); + await expect(panel).toHaveCount(0); + await expect(page.getByTestId("threads-view")).toBeVisible(); + }); + + test("ArrowUp in the Threads view composer edits your last message", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + const now = Math.floor(Date.now() / 1000); + const rootId = await openThread(page, "Root before my last reply.", { + replyCreatedAt: now + 120, + }); + await nameOpenThread(page, "Up to edit"); + // Newer than the root but older than Alice's reply, so ArrowUp has to + // skip someone else's newer message and pick this one over the root. + const ownReplyId = await page.evaluate( + ({ channelName, createdAt, parentEventId, pubkey }) => + (window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + content: "My last reply.", + createdAt, + parentEventId, + pubkey, + })?.id ?? null, + { + channelName: CHANNEL_NAME, + createdAt: now + 60, + parentEventId: rootId, + pubkey: MOCK_IDENTITY_PUBKEY, + }, + ); + expect(ownReplyId).not.toBeNull(); + + await page.getByTestId("open-threads-view").click(); + await page.getByTestId("threads-view-row").first().click(); + const panel = page.getByTestId("message-thread-panel"); + await expect(panel).toContainText("My last reply."); + + await panel.getByTestId("message-input").click(); + await page.keyboard.press("ArrowUp"); + await expect(panel.getByTestId("edit-target")).toBeVisible(); + await expect(panel.getByTestId("message-input")).toHaveText( + "My last reply.", + ); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.type("My last reply, edited."); + await page.keyboard.press("Enter"); + await expect(panel.getByTestId("edit-target")).toBeHidden(); + await expect( + panel.locator(`[data-message-id="${ownReplyId}"]`), + ).toContainText("My last reply, edited."); + }); + + test("clearing the title removes the thread from the Threads view", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + await openThread(page, "Root message that is named, then cleared."); + + const panel = page.getByTestId("message-thread-panel"); + await panel.getByTestId("thread-title").hover(); + await panel.getByTestId("thread-title-edit").click(); + await panel.getByTestId("thread-title-input").fill("Temporary name"); + await panel.getByTestId("thread-title-input").press("Enter"); + await expect(panel.getByTestId("thread-title")).toHaveText( + "Temporary name", + ); + + 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 page.getByTestId("open-threads-view").click(); + await expect(page.getByTestId("threads-view")).toContainText( + "No named threads", + ); + await expect(page.getByTestId("threads-view-subheader")).toHaveText( + "Named threads, most recent activity first. Name any thread from its header.", + ); + }); + + test("unread replies in a named thread show in the Threads view and sidebar", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + const rootId = await openThread( + page, + "Root message that gets new replies.", + ); + await nameOpenThread(page, "Unread tracking"); + + await page.getByTestId("open-threads-view").click(); + const row = page.getByTestId("threads-view-row"); + await expect(row).toHaveCount(1); + await expect(page.getByTestId("threads-unread-dot")).toHaveCount(0); + + await emitReply(page, rootId, "A reply while you're away."); + await emitReply(page, rootId, "And another one."); + await expect(page.getByTestId("threads-unread-dot")).toBeVisible(); + await expect(row).toHaveAttribute("data-unread", "true"); + await expect(page.getByTestId("threads-view-row-unread")).toHaveText( + "2 new", + ); + + // Opening the thread reads it, which clears both indicators. + await row.click(); + await expect( + page + .getByTestId("message-thread-panel") + .getByTestId("message-thread-open-channel"), + ).toHaveText(`Unread tracking · #${CHANNEL_NAME}`); + await expect(page.getByTestId("threads-unread-dot")).toHaveCount(0); + await page.getByTestId("open-threads-view").click(); + await expect(row).not.toHaveAttribute("data-unread", "true"); + }); + + test("mark a reply unread from the Threads view", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + const rootId = await openThread(page, "Root of a thread to revisit."); + await nameOpenThread(page, "Revisit later"); + await emitReply(page, rootId, "A reply to come back to."); + + await page.getByTestId("open-threads-view").click(); + const row = page.getByTestId("threads-view-row"); + await row.click(); + const panel = page.getByTestId("message-thread-panel"); + await expect(panel).toContainText("A reply to come back to."); + await expect(row).not.toHaveAttribute("data-unread", "true"); + + const replyId = await panel + .locator("[data-message-id]") + .filter({ hasText: "A reply to come back to." }) + .last() + .getAttribute("data-message-id"); + await panel.locator(`[data-message-id="${replyId}"]`).hover(); + await page.getByTestId(`more-actions-${replyId}`).click(); + await page.getByTestId(`mark-read-toggle-${replyId}`).click(); + + // The open thread must not read the reply straight back. + await expect(page.getByTestId("threads-view-row-unread")).toHaveText( + "1 new", + ); + await expect(page.getByTestId("threads-unread-dot")).toBeVisible(); + + // Back on the list it is still unread; reopening the thread reads it. + await page.getByTestId("open-threads-view").click(); + await expect(panel).toHaveCount(0); + await expect(row).toHaveAttribute("data-unread", "true"); + await row.click(); + await expect(panel).toContainText("A reply to come back to."); + await expect(row).not.toHaveAttribute("data-unread", "true"); + await expect(page.getByTestId("threads-unread-dot")).toHaveCount(0); + }); + + test("the Threads view pane marks where the new replies start", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + const rootId = await openThread(page, "Root of a thread with news."); + await nameOpenThread(page, "News thread"); + + await page.getByTestId("open-threads-view").click(); + const row = page.getByTestId("threads-view-row"); + // Until the channel's thread panel unmounts it reads new replies itself. + await expect(row).toHaveCount(1); + await emitReply(page, rootId, "The first new reply."); + await emitReply(page, rootId, "The second new reply."); + await expect(page.getByTestId("threads-view-row-unread")).toHaveText( + "2 new", + ); + + // Opening reads the thread, but the divider still marks what was new. + await row.click(); + const panel = page.getByTestId("message-thread-panel"); + await expect(panel).toContainText("The second new reply."); + await expect(row).not.toHaveAttribute("data-unread", "true"); + const divider = panel.getByTestId("message-unread-divider"); + await expect(divider).toHaveCount(1); + const top = async (text: string) => + (await panel.getByText(text, { exact: true }).boundingBox())?.y ?? 0; + const dividerTop = (await divider.boundingBox())?.y ?? 0; + expect(dividerTop).toBeGreaterThan(await top("First reply.")); + expect(dividerTop).toBeLessThan(await top("The first new reply.")); + }); + + test("Mark read in the Threads view pane undoes Mark unread", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + const rootId = await openThread(page, "Root of a thread to read twice."); + await nameOpenThread(page, "Read twice"); + await emitReply(page, rootId, "A reply to flag and unflag."); + + await page.getByTestId("open-threads-view").click(); + const row = page.getByTestId("threads-view-row"); + await row.click(); + const panel = page.getByTestId("message-thread-panel"); + await expect(panel).toContainText("A reply to flag and unflag."); + + const toggle = await openReplyMenu(page, "A reply to flag and unflag."); + await expect(toggle).toHaveText("Mark unread"); + await toggle.click(); + await expect(page.getByTestId("threads-view-row-unread")).toHaveText( + "1 new", + ); + + const sameToggle = await openReplyMenu(page, "A reply to flag and unflag."); + await expect(sameToggle).toHaveText("Mark read"); + await sameToggle.click(); + await expect(row).not.toHaveAttribute("data-unread", "true"); + await expect(page.getByTestId("threads-unread-dot")).toHaveCount(0); + + // With nothing left marked unread, the open thread reads new replies again. + await emitReply(page, rootId, "A reply after reading it all."); + await expect(panel).toContainText("A reply after reading it all."); + await expect(row).not.toHaveAttribute("data-unread", "true"); + }); + + test("the Threads view pane shows who is typing and which agents are working", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + // Typing right after your own message is suppressed, so the agent must + // not be the one who wrote the first reply. + const rootId = await openThread(page, "Root of a thread with an agent.", { + channelName: "agents", + replyPubkey: TEST_IDENTITIES.bob.pubkey, + }); + await nameOpenThread(page, "Agent thread"); + + await page.getByTestId("open-threads-view").click(); + await page.getByTestId("threads-view-row").click(); + const panel = page.getByTestId("message-thread-panel"); + await expect(panel).toContainText("Root of a thread with an agent."); + await expect + .poll(() => + page.evaluate( + () => + ( + window as MockMessageWindow + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "agents", + kind: 20002, + }) ?? false, + ), + ) + .toBe(true); + + const emitTyping = (pubkey: string) => + page.evaluate( + ({ pubkey, threadHeadId }) => { + (window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_TYPING__?.({ + channelName: "agents", + pubkey, + threadHeadId, + }); + }, + { pubkey, threadHeadId: rootId }, + ); + // Alice is an agent in #agents; Charlie is not. + await emitTyping(TEST_IDENTITIES.alice.pubkey); + await expect( + panel.getByTestId("bot-activity-composer-trigger"), + ).toBeVisible(); + await emitTyping(TEST_IDENTITIES.charlie.pubkey); + await expect(panel.getByTestId("message-typing-indicator")).toBeVisible(); + }); + + test("View activity log from the Threads view pane opens the thread's channel", async ({ + page, + }) => { + const agentPubkey = + "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00"; + // #general comes first in the agent's channels, so the route fallback + // would land there instead of the thread's channel. + await installMockBridge(page, { + relayAgents: [ + { + pubkey: agentPubkey, + name: "nadia", + channelNames: ["general", "agents"], + respondTo: "anyone", + }, + ], + }); + await page.goto("/"); + await openThread(page, "Root of a thread nadia replied to.", { + channelName: "agents", + replyPubkey: agentPubkey, + }); + await nameOpenThread(page, "Nadia thread"); + + await page.getByTestId("open-threads-view").click(); + await page.getByTestId("threads-view-row").click(); + const panel = page.getByTestId("message-thread-panel"); + const reply = panel + .getByTestId("message-row") + .filter({ hasText: "First reply." }); + await reply.getByTestId("message-author").hover(); + const viewActivity = page.getByTestId( + `user-profile-view-activity-${agentPubkey}`, + ); + await viewActivity.click(); + + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + await expect(page).toHaveURL( + new RegExp( + `/channels/94a444a4-c0a3-5966-ab05-530c6ddc2301\\?.*agentSession=${agentPubkey}`, + ), + ); + }); + + test("a Threads row shows a live timer while an agent works in that thread", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + const workedRootId = await openThread(page, "Thread an agent works in.", { + channelName: "agents", + }); + await nameOpenThread(page, "Worked thread"); + await openThread(page, "Thread nobody works in.", { + channelName: "agents", + }); + await nameOpenThread(page, "Idle thread"); + const channelId = await page + .getByTestId("channel-agents") + .getAttribute("data-channel-id"); + if (!channelId) throw new Error("#agents is missing its channel id."); + + await page.getByTestId("open-threads-view").click(); + const workedRow = page + .getByTestId("threads-view-row") + .filter({ hasText: "Worked thread" }); + const idleRow = page + .getByTestId("threads-view-row") + .filter({ hasText: "Idle thread" }); + await expect(workedRow).toBeVisible(); + await expect(idleRow).toBeVisible(); + + const seedTurn = ( + kind: "turn_started" | "turn_completed", + threadRootEventId?: string, + ) => + page.evaluate( + (input) => { + (window as MockMessageWindow).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.(input); + }, + { + agentPubkey: TEST_IDENTITIES.alice.pubkey, + channelId, + kind, + threadRootEventId, + turnId: "thread-row-timer", + }, + ); + + const sidebarBadge = page.getByTestId("threads-working"); + + // A channel-scoped turn has no thread, so no row lights up. + await seedTurn("turn_started"); + await expect(page.getByTestId("threads-view-row-working")).toHaveCount(0); + await expect(sidebarBadge).toHaveCount(0); + await seedTurn("turn_completed"); + + // An untitled thread isn't listed, so the sidebar entry stays dark too. + await seedTurn("turn_started", "c".repeat(64)); + await expect(page.getByTestId("threads-view-row-working")).toHaveCount(0); + await expect(sidebarBadge).toHaveCount(0); + await seedTurn("turn_completed", "c".repeat(64)); + + await seedTurn("turn_started", workedRootId); + await expect(workedRow.getByTestId("threads-view-row-working")).toHaveText( + /^\d+s$/, + ); + await expect(idleRow.getByTestId("threads-view-row-working")).toHaveCount( + 0, + ); + await expect(sidebarBadge).toHaveText(/^\d+s$/); + await expect(sidebarBadge).toHaveAttribute("title", "1 thread working"); + + await seedTurn("turn_completed", workedRootId); + await expect(page.getByTestId("threads-view-row-working")).toHaveCount(0); + await expect(sidebarBadge).toHaveCount(0); + }); + + test("the selected Threads item shows its unread dot while a thread is open", async ({ + page, + }) => { + await page.addInitScript(() => { + localStorage.setItem("buzz.channels.threadViewMode", "focus"); + // A theme whose selected row is filled with the blue accent, where a + // primary-colored dot disappears. + localStorage.setItem("buzz-theme", "catppuccin-latte"); + localStorage.setItem("buzz-accent-color", "#3b82f6"); + }); + await installMockBridge(page); + await page.goto("/"); + const otherRoot = await openThread(page, "Root of the other thread."); + await nameOpenThread(page, "Other thread"); + await openThread(page, "Root of the thread being read.", { + replyCreatedAt: Math.floor(Date.now() / 1000) + 60, + }); + await nameOpenThread(page, "Thread being read"); + await page.keyboard.press("Escape"); + + await page.getByTestId("open-threads-view").click(); + await page + .getByTestId("threads-view-row") + .filter({ hasText: "Thread being read" }) + .click(); + await expect(page.getByTestId("focus-thread-drawer")).toBeVisible(); + + // The drawer covers the other row's "1 new", so the sidebar has to say it. + await emitReply(page, otherRoot, "News in the other thread."); + const dot = page.getByTestId("threads-unread-dot"); + await expect(dot).toBeVisible(); + // Drawn in the selected row's text color, so it shows on any row fill + // (a primary dot vanished on the primary-filled selection). + const colors = await dot.evaluate((element) => { + const button = element.closest("button") as HTMLElement; + return { + fill: getComputedStyle(button).backgroundColor, + text: getComputedStyle(button).color, + dot: getComputedStyle(element).backgroundColor, + }; + }); + expect(colors.dot).toBe(colors.text); + expect(colors.dot).not.toBe(colors.fill); + }); + + test("a pinned thread stays above more recently active ones", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + await openThread(page, "Root of the thread to pin."); + await nameOpenThread(page, "Pin me"); + // Both threads are created within the same second; without a later reply + // their activity ties and the "most recent first" order is arbitrary. + const busyRoot = await openThread(page, "Root of a busier thread.", { + replyCreatedAt: Math.floor(Date.now() / 1000) + 60, + }); + await nameOpenThread(page, "Busy thread"); + + await page.getByTestId("open-threads-view").click(); + const rows = page.getByTestId("threads-view-row"); + await expect(rows).toHaveText([/Busy thread/, /Pin me/]); + await expect(page.getByTestId("threads-view-pinned")).toHaveCount(0); + + const pinMe = page + .getByRole("listitem") + .filter({ hasText: "Pin me" }) + .getByTestId("threads-view-row-pin"); + await pinMe.click(); + await expect(pinMe).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("threads-view-pinned")).toContainText( + "Pin me", + ); + await expect(rows).toHaveText([/Pin me/, /Busy thread/]); + + // New activity elsewhere doesn't move a thread above a pin, and the pin + // survives leaving and reopening the view. + await emitReply(page, busyRoot, "More activity in the busy thread."); + await page.getByTestId(`channel-${CHANNEL_NAME}`).click(); + await page.getByTestId("open-threads-view").click(); + await expect(rows).toHaveText([/Pin me/, /Busy thread/]); + + await pinMe.click(); + await expect(page.getByTestId("threads-view-pinned")).toHaveCount(0); + await expect(pinMe).toHaveAttribute("aria-pressed", "false"); + await expect(rows).toHaveText([/Busy thread/, /Pin me/]); + }); + + 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); + + // Visit Threads first so the (empty) title list is cached and fresh. + await page.getByTestId("open-threads-view").click(); + await expect(page.getByTestId("threads-view-row")).toHaveCount(0); + await page.getByTestId(`channel-${CHANNEL_NAME}`).click(); + + 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 }, + ); + + // Well inside the 30s staleTime: only the live refresh can deliver it. + await expect(page.getByTestId("thread-title-line")).toHaveText( + "Agent-named thread", + { timeout: 5_000 }, + ); + await page.getByTestId("open-threads-view").click(); + await expect(page.getByTestId("threads-view-row")).toHaveText([ + /Agent-named thread/, + ]); + }); + + test("filter the Threads view by channel", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + await setRemoteTitles(page, [ + { channelName: "random", rootId: "c".repeat(64), title: "Random one" }, + { channelName: CHANNEL_NAME, rootId: "a".repeat(64), title: "Eng one" }, + { channelName: CHANNEL_NAME, rootId: "b".repeat(64), title: "Eng two" }, + ]); + + await page.getByTestId("open-threads-view").click(); + const rows = page.getByTestId("threads-view-row"); + await expect(rows).toHaveCount(3); + const filter = page.getByTestId("threads-channel-filter"); + await expect(filter).toHaveText("All channels"); + + await filter.click(); + await expect(page.getByTestId("threads-channel-filter-option")).toHaveText([ + /All channels\s*3/, + /#engineering\s*2/, + /#random\s*1/, + ]); + await page + .getByTestId("threads-channel-filter-option") + .filter({ hasText: "#engineering" }) + .click(); + await expect(filter).toHaveText(`#${CHANNEL_NAME}`); + await expect(rows).toHaveCount(2); + await expect(rows.filter({ hasText: "Random one" })).toHaveCount(0); + + // The filter holds across leaving and coming back. + await page.getByTestId(`channel-${CHANNEL_NAME}`).click(); + await page.getByTestId("open-threads-view").click(); + await expect(filter).toHaveText(`#${CHANNEL_NAME}`); + await expect(rows).toHaveCount(2); + + await filter.click(); + await page + .getByTestId("threads-channel-filter-option") + .filter({ hasText: "All channels" }) + .click(); + await expect(rows).toHaveCount(3); + }); + + test("a thread in a channel the user hasn't joined still opens the channel", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + await setRemoteTitles(page, [ + { channelName: "design", rootId: "d".repeat(64), title: "Design review" }, + { + channelName: CHANNEL_NAME, + rootId: "e".repeat(64), + title: "Eng thread", + }, + ]); + + await page.getByTestId("open-threads-view").click(); + await page + .getByTestId("threads-view-row") + .filter({ hasText: "Design review" }) + .click(); + await expect(page.getByTestId("chat-title")).toHaveText("design"); + }); + + test("Threads follows the Focus thread layout and can switch to beside", async ({ + page, + }) => { + await page.addInitScript(() => { + localStorage.setItem("buzz.channels.threadViewMode", "focus"); + }); + await installMockBridge(page); + await page.goto("/"); + await openThread(page, "Root message for the focus drawer."); + await nameOpenThread(page, "Focus drawer thread"); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("focus-thread-drawer")).toHaveCount(0); + + await page.getByTestId("open-threads-view").click(); + const row = page.getByTestId("threads-view-row"); + await row.click(); + + // Focus opens the large drawer over the list, which goes inert. + const drawer = page.getByTestId("focus-thread-drawer"); + await expect(drawer.getByTestId("message-thread-open-channel")).toHaveText( + `Focus drawer thread · #${CHANNEL_NAME}`, + ); + await expect(page.getByTestId("threads-view")).toBeVisible(); + await expect( + page.locator("[inert]").getByTestId("threads-view"), + ).toHaveCount(1); + + // The scrim goes back to the list. + await page + .getByRole("button", { name: "Back to Threads" }) + .click({ position: { x: 20, y: 300 } }); + await expect(drawer).toHaveCount(0); + await expect( + page.locator("[inert]").getByTestId("threads-view"), + ).toHaveCount(0); + + // The header toggle switches to the side pane and saves the choice. + await row.click(); + await drawer.getByTestId("thread-view-mode-toggle").click(); + await expect(drawer).toHaveCount(0); + const sidePanel = page.getByTestId("message-thread-panel"); + await expect( + sidePanel.getByTestId("message-thread-open-channel"), + ).toHaveText(`Focus drawer thread · #${CHANNEL_NAME}`); + await expect( + page.locator("[inert]").getByTestId("threads-view"), + ).toHaveCount(0); + expect( + await page.evaluate(() => + localStorage.getItem("buzz.channels.threadViewMode"), + ), + ).toBe("split"); + + // And back to Focus from the side pane. + await sidePanel.getByTestId("thread-view-mode-toggle").click(); + await expect(drawer.getByTestId("message-thread-open-channel")).toHaveText( + `Focus drawer thread · #${CHANNEL_NAME}`, + ); + }); + + test("clicking Threads in the sidebar closes the open thread", async ({ + page, + }) => { + await page.addInitScript(() => { + localStorage.setItem("buzz.channels.threadViewMode", "focus"); + }); + await installMockBridge(page); + await page.goto("/"); + await openThread(page, "Root message for the sidebar reset."); + await nameOpenThread(page, "Sidebar reset thread"); + await page.keyboard.press("Escape"); + + await page.getByTestId("open-threads-view").click(); + const row = page.getByTestId("threads-view-row"); + const drawer = page.getByTestId("focus-thread-drawer"); + + // Focus: the drawer covers the list, but not the sidebar. + await row.click(); + await expect(drawer.getByTestId("message-thread-open-channel")).toHaveText( + `Sidebar reset thread · #${CHANNEL_NAME}`, + ); + await page.getByTestId("open-threads-view").click(); + await expect(drawer).toHaveCount(0); + await expect( + page.locator("[inert]").getByTestId("threads-view"), + ).toHaveCount(0); + + // Beside: the side pane closes the same way. + await row.click(); + await drawer.getByTestId("thread-view-mode-toggle").click(); + const sidePanel = page.getByTestId("message-thread-panel"); + await expect( + sidePanel.getByTestId("message-thread-open-channel"), + ).toHaveText(`Sidebar reset thread · #${CHANNEL_NAME}`); + await page.getByTestId("open-threads-view").click(); + await expect(sidePanel).toHaveCount(0); + await expect(row).not.toHaveAttribute("data-selected", "true"); + + // A reopen still works after a reset. + await row.click(); + await expect( + sidePanel.getByTestId("message-thread-open-channel"), + ).toHaveText(`Sidebar reset thread · #${CHANNEL_NAME}`); + }); +});