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