diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index ea11fce12f..3ac7147091 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -20,6 +20,9 @@ use crate::usage::{TurnUsage, UsageTracker}; /// Lines exceeding this limit are rejected to prevent OOM from rogue agents. const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB +/// Byte cap for [`AcpClient::begin_message_capture`] accumulation. +const MESSAGE_CAPTURE_MAX_LEN: usize = 4_096; + /// An MCP server configuration passed to `session/new`. /// /// Corresponds to the `McpServerStdio` variant in the ACP schema. @@ -173,6 +176,11 @@ pub struct AcpClient { /// deltas. Both goose and buzz-agent emit this notification; goose gates /// on client capability advertisement, buzz-agent emits unconditionally. goose_usage: UsageTracker, + /// Accumulates `agent_message_chunk` text while a capture is active. + /// Used by side prompts (e.g. chat-title generation) that need the + /// assistant's reply text, which is otherwise only streamed to logs. + /// `None` = capture inactive. + message_capture: Option, } impl AcpClient { @@ -265,6 +273,7 @@ impl AcpClient { active_run_id: None, steer_rx: None, goose_usage: UsageTracker::default(), + message_capture: None, }) } @@ -301,6 +310,20 @@ impl AcpClient { } } + /// Start capturing streamed assistant text (`agent_message_chunk`). + /// + /// Used by side prompts (e.g. chat-title generation) whose reply the + /// harness needs as a string. Call before `session_prompt_*`, then + /// [`take_message_capture`](Self::take_message_capture) after it returns. + pub fn begin_message_capture(&mut self) { + self.message_capture = Some(String::new()); + } + + /// Stop capturing and return the accumulated assistant text. + pub fn take_message_capture(&mut self) -> Option { + self.message_capture.take() + } + /// Send the `initialize` request and return the agent's response result value. /// /// Must be called exactly once, before any other ACP method. @@ -1259,6 +1282,18 @@ impl AcpClient { "agent_message_chunk" => { if let Some(text) = update["content"]["text"].as_str() { tracing::info!(target: "acp::stream", "{text}"); + if let Some(capture) = self.message_capture.as_mut() { + // Side prompts expect short replies; the cap bounds a + // runaway stream, not legitimate output. + if capture.len() < MESSAGE_CAPTURE_MAX_LEN { + let budget = MESSAGE_CAPTURE_MAX_LEN - capture.len(); + let mut end = budget.min(text.len()); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + capture.push_str(&text[..end]); + } + } } false } diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index fe2ea7107b..9b17332c6d 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -47,8 +47,9 @@ All replies and delegations — including task assignments to other agents — g ### General - Respond promptly to @mentions. Be direct — no preamble. Name what you did, what you found, or what you need. -- **Every turn that processes a user message MUST end with `buzz messages send`.** Your reasoning and tool calls are invisible to users — if you didn't send a message, they saw nothing. A turn that ends without a sent message is a silent failure. +- **Every turn that processes a user message MUST end with `buzz messages send --channel --content "..."`.** Message text is not positional — always pass it with `--content`, or pipe it with `--content -` for shell-sensitive text. Your reasoning and tool calls are invisible to users — if you didn't send a message, they saw nothing. A turn that ends without a sent message is a silent failure. - For work that requires follow-up tools, create an open todo **before** sending the pickup acknowledgment. Keep it open until the deliverable is verified and you have sent a completion or blocker message; never end a turn with open todo state unless you have posted that completion or blocker message. +- Do not narrate that you are about to send a reply, or that you sent one. Put the actual human-facing answer in the final `buzz messages send` content only. - Use GitHub-flavored Markdown. Fenced code blocks with language tags for syntax highlighting. - No push notifications — poll with `buzz messages get --channel --since `. - Address people by the name in their own message header. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index d139dc6cd1..2f115a95cf 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -436,6 +436,16 @@ pub struct ChannelFilter { pub require_mention: bool, } +/// Chat channels are single-agent addressed even when multiple agents are +/// members: the desktop client writes the default agent as an implicit `p` tag +/// and explicit @mentions add more `p` tags. Force `#p` filtering so broad +/// agent configs do not fan every chat turn out to every attached agent. +pub fn force_mention_filter_for_chat(filter: &mut ChannelFilter, channel_type: Option<&str>) { + if channel_type == Some("chat") { + filter.require_mention = true; + } +} + #[derive(Debug)] pub struct Config { pub keys: Keys, @@ -1414,6 +1424,30 @@ mod tests { assert!(!f.require_mention); } + #[test] + fn test_chat_channels_force_mention_filter() { + let mut filter = ChannelFilter { + kinds: None, + require_mention: false, + }; + + force_mention_filter_for_chat(&mut filter, Some("chat")); + + assert!(filter.require_mention); + } + + #[test] + fn test_non_chat_channels_keep_resolved_filter() { + let mut filter = ChannelFilter { + kinds: None, + require_mention: false, + }; + + force_mention_filter_for_chat(&mut filter, Some("stream")); + + assert!(!filter.require_mention); + } + #[test] fn normalizes_goose_args_to_acp() { assert_eq!(normalize_agent_args("goose", Vec::new()), vec!["acp"]); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 9fa46e5ce3..e019865448 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -81,6 +81,129 @@ async fn publish_presence( Ok(()) } +fn channel_is_chat(channel_info: &HashMap, channel_id: Uuid) -> bool { + channel_info + .get(&channel_id) + .is_some_and(|info| info.channel_type == "chat") +} + +/// Oldest chat backlog the startup replay will deliver. Bounds necro-replies +/// to abandoned chats while comfortably covering the activation flow (owner +/// messages a stopped agent, then starts it from the desktop). +const CHAT_BACKLOG_MAX_AGE_SECS: u64 = 6 * 60 * 60; + +/// Replay floor for a chat channel's startup subscription. +/// +/// A chat's owner often messages while the agent is stopped — the desktop's +/// "Activate agent" card starts the agent on demand — and a `since: now` +/// subscription would silently skip that pending message forever. Replay from +/// just after the agent's own last reply in the channel (it has answered +/// everything before that), capped to a recent window. Best-effort: on query +/// failure, fall back to plain live-only subscription. +async fn chat_backlog_replay_since( + rest: &relay::RestClient, + channel_id: Uuid, + agent_pubkey_hex: &str, + startup_watermark: u64, +) -> Option { + let floor = startup_watermark.saturating_sub(CHAT_BACKLOG_MAX_AGE_SECS); + let author = nostr::PublicKey::from_hex(agent_pubkey_hex).ok()?; + let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); + let filter = nostr::Filter::new() + .kind(nostr::Kind::Custom(9)) + .author(author) + .custom_tags(h_tag, [channel_id.to_string()]) + .limit(1); + + match rest.query(&[filter]).await { + Ok(value) => { + let last_reply_ts = value.as_array().and_then(|events| { + events + .iter() + .filter_map(|event| event.get("created_at").and_then(|v| v.as_u64())) + .max() + }); + Some(match last_reply_ts { + Some(ts) => ts.saturating_add(1).max(floor), + None => floor, + }) + } + Err(error) => { + tracing::debug!("chat backlog query failed for {channel_id}: {error}"); + None + } + } +} + +/// Reclassify compatibility chats. +/// +/// Relays that predate `channel_type: chat` store chats as plain private +/// channels — the chat-ness lives only in a kind 30623 (or legacy 30078) +/// chat-metadata event. Without this, every chat behavior keyed on +/// `channel_type == "chat"` (activation backlog replay, mention gating, +/// reaction suppression, title emission) silently degrades against staging. +async fn mark_compat_chats( + rest: &relay::RestClient, + channel_info: &mut HashMap, +) { + const KIND_CHAT_METADATA: u16 = 30623; + const LEGACY_CHAT_METADATA_KIND: u16 = 30078; + const LEGACY_D_PREFIX: &str = "buzz:chat:"; + + if channel_info + .values() + .all(|info| info.channel_type == "chat") + { + return; + } + let filters = [ + nostr::Filter::new() + .kind(nostr::Kind::Custom(KIND_CHAT_METADATA)) + .limit(500), + nostr::Filter::new() + .kind(nostr::Kind::Custom(LEGACY_CHAT_METADATA_KIND)) + .limit(1000), + ]; + let value = match rest.query(&filters).await { + Ok(value) => value, + Err(error) => { + tracing::debug!("compat chat metadata query failed: {error}"); + return; + } + }; + let mut reclassified = 0usize; + for event in value.as_array().into_iter().flatten() { + let kind = event.get("kind").and_then(|v| v.as_u64()).unwrap_or(0); + let tag_value = |name: &str| -> Option { + event.get("tags")?.as_array()?.iter().find_map(|tag| { + let tag = tag.as_array()?; + (tag.first()?.as_str()? == name) + .then(|| tag.get(1)?.as_str().map(str::to_string)) + .flatten() + }) + }; + let channel_id = if kind == u64::from(LEGACY_CHAT_METADATA_KIND) { + tag_value("chat_h").or_else(|| { + tag_value("d").and_then(|d| d.strip_prefix(LEGACY_D_PREFIX).map(str::to_string)) + }) + } else { + tag_value("d") + }; + let Some(channel_id) = channel_id.and_then(|id| id.parse::().ok()) else { + continue; + }; + if let Some(info) = channel_info.get_mut(&channel_id) { + if info.channel_type != "chat" { + info.channel_type = "chat".to_string(); + reclassified += 1; + } + } + } + if reclassified > 0 { + tracing::info!("reclassified {reclassified} compatibility chat channel(s)"); + } +} + /// Resolve the agent's owner pubkey at startup. /// /// Priority: @@ -1328,10 +1451,12 @@ async fn tokio_main() -> Result<()> { } } - let channel_info_map = relay + let mut channel_info_map = relay .discover_channels() .await .map_err(|e| anyhow::anyhow!("channel discovery error: {e}"))?; + mark_compat_chats(&relay.rest_client(), &mut channel_info_map).await; + let channel_info_map = channel_info_map; tracing::info!("discovered {} channel(s)", channel_info_map.len()); let channel_ids: Vec = channel_info_map.keys().copied().collect(); @@ -1373,13 +1498,36 @@ async fn tokio_main() -> Result<()> { } }; - let channel_filters = config::resolve_channel_filters(&config, &channel_ids, &rules); + let mut channel_filters = config::resolve_channel_filters(&config, &channel_ids, &rules); + for (channel_id, filter) in channel_filters.iter_mut() { + config::force_mention_filter_for_chat( + filter, + channel_info_map + .get(channel_id) + .map(|info| info.channel_type.as_str()), + ); + } + let mut event_channel_info = channel_info_map.clone(); if channel_filters.is_empty() { tracing::warn!("no channel subscriptions resolved — agent will sit idle"); } + let backlog_rest = relay.rest_client(); for (channel_id, filter) in &channel_filters { - if let Err(e) = relay.subscribe_channel(*channel_id, filter.clone()).await { + // Chats replay their unanswered backlog; other channel types stay + // live-only from the startup watermark. + let replay_since = if channel_is_chat(&channel_info_map, *channel_id) { + chat_backlog_replay_since(&backlog_rest, *channel_id, &pubkey_hex, startup_watermark) + .await + } else { + None + }; + if let Err(e) = relay + .subscribe_channel_from(*channel_id, filter.clone(), replay_since) + .await + { tracing::warn!("failed to subscribe to channel {channel_id}: {e}"); + } else if let Some(since) = replay_since { + tracing::info!("subscribed to channel {channel_id} (chat backlog since {since})"); } else { tracing::info!("subscribed to channel {channel_id}"); } @@ -1420,6 +1568,7 @@ async fn tokio_main() -> Result<()> { .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()), memory_enabled: config.memory_enabled, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), + titled_channels: std::sync::Mutex::new(std::collections::HashSet::new()), }); if !config.memory_enabled { @@ -1748,7 +1897,29 @@ async fn tokio_main() -> Result<()> { // stripped for a legitimately re-added channel. removed_channels.remove(&ch); - if let Some(filter) = config::resolve_dynamic_channel_filter(&config, ch, &rules) { + if let Some(mut filter) = config::resolve_dynamic_channel_filter(&config, ch, &rules) { + match relay.discover_channels().await { + Ok(discovered) => { + event_channel_info.extend(discovered); + mark_compat_chats( + &ctx.rest_client, + &mut event_channel_info, + ) + .await; + } + Err(e) => { + tracing::debug!( + channel_id = %ch, + "membership notification: channel metadata refresh failed: {e}" + ); + } + } + config::force_mention_filter_for_chat( + &mut filter, + event_channel_info + .get(&ch) + .map(|info| info.channel_type.as_str()), + ); tracing::info!(channel_id = %ch, "membership notification: subscribing to new channel"); if let Err(e) = relay.subscribe_channel_from(ch, filter, Some(ts)).await { tracing::warn!("failed to subscribe to new channel {ch}: {e}"); @@ -1770,6 +1941,7 @@ async fn tokio_main() -> Result<()> { // Track removed channels so checked-out agents get // their sessions stripped when they return to the pool. removed_channels.insert(ch); + event_channel_info.remove(&ch); typing_channels.remove(&ch); // Best-effort: clean up 👀 on drained events. // Note: the relay revokes membership before @@ -1938,6 +2110,17 @@ async fn tokio_main() -> Result<()> { } } + if channel_is_chat(&event_channel_info, buzz_event.channel_id) + && !event_mentions_agent(&buzz_event.event, &pubkey_hex) + { + tracing::debug!( + channel_id = %buzz_event.channel_id, + kind = buzz_event.event.kind.as_u16(), + "chat event did not mention this agent — dropping" + ); + continue; + } + let matched = filter::match_event(&buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex).await; let prompt_tag = match matched { Some(m) => m.prompt_tag, @@ -1973,7 +2156,15 @@ async fn tokio_main() -> Result<()> { // Fire-and-forget: on rare fast-failure paths the // guard's cleanup may race with this add, leaving a // cosmetic stale 👀. Acceptable — see ReactionGuard docs. - if accepted { + // Chats skip it: their UI has live working + // indicators, and reaction emoji on the user's + // message reads as noise there. + if accepted + && !channel_is_chat( + &event_channel_info, + buzz_event.channel_id, + ) + { let rc = ctx.rest_client.clone(); let eid = event_id_hex.clone(); tokio::spawn(async move { diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 6a834f77cd..c730628a7c 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -80,6 +80,8 @@ pub struct AgentModelCapabilities { pub struct SessionState { /// channel_id → session_id pub sessions: HashMap, + /// channel_id → cwd used when the session was created. + pub channel_cwds: HashMap, pub heartbeat_session: Option, /// Per-channel turn counters for proactive session rotation. /// Incremented on each successful prompt; reset when the session is rotated. @@ -110,12 +112,14 @@ impl SessionState { pub fn invalidate_channel(&mut self, channel_id: &Uuid) -> bool { self.turn_counts.remove(channel_id); self.core_sections.remove(channel_id); + self.channel_cwds.remove(channel_id); self.sessions.remove(channel_id).is_some() } /// Invalidate all sessions and turn counters (e.g. after agent exit). pub fn invalidate_all(&mut self) { self.sessions.clear(); + self.channel_cwds.clear(); self.turn_counts.clear(); self.heartbeat_session = None; self.heartbeat_turn_count = 0; @@ -127,6 +131,7 @@ impl SessionState { self.sessions.contains_key(channel_id) || self.turn_counts.contains_key(channel_id) || self.core_sections.contains_key(channel_id) + || self.channel_cwds.contains_key(channel_id) } } @@ -394,6 +399,10 @@ pub struct PromptContext { /// Harness identity string for NIP-AM `harness` field. Derived from the /// configured `agent_command` at startup (e.g. `"goose"`, `"buzz-agent"`). pub harness_name: String, + /// Chat channels that already produced a `chat_title` observer frame this + /// process. Interior mutability because `PromptContext` is `Arc`-shared + /// across prompt tasks. + pub titled_channels: std::sync::Mutex>, } impl AgentPool { @@ -662,6 +671,7 @@ const PERMISSION_MODE_TIMEOUT: Duration = Duration::from_secs(5); async fn create_session_and_apply_model( agent: &mut OwnedAgent, ctx: &PromptContext, + cwd: &str, agent_core: Option<&str>, ) -> Result { // Combine base_prompt + system_prompt + agent core into a single @@ -672,7 +682,7 @@ async fn create_session_and_apply_model( // header from `engram_fetch::build_core_section`, so we just append it. let combined_system_prompt: Option = if agent.protocol_version >= 2 { with_core( - framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), + framed_system_prompt(cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), agent_core, ) } else { @@ -682,7 +692,7 @@ async fn create_session_and_apply_model( let resp = agent .acp .session_new_full( - &ctx.cwd, + cwd, ctx.mcp_servers.clone(), combined_system_prompt.as_deref(), ) @@ -1096,6 +1106,27 @@ pub async fn run_prompt_task( turn_id.clone(), ); + let session_cwd = match &source { + PromptSource::Channel(cid) => resolve_channel_session_cwd(*cid, &ctx) + .await + .unwrap_or_else(|| ctx.cwd.clone()), + PromptSource::Heartbeat => ctx.cwd.clone(), + }; + if let PromptSource::Channel(cid) = &source { + let has_session = agent.state.sessions.contains_key(cid); + let previous_cwd = agent.state.channel_cwds.get(cid).map(String::as_str); + if has_session && previous_cwd != Some(session_cwd.as_str()) { + tracing::info!( + target: "pool::session", + channel = %cid, + cwd = %session_cwd, + "channel session cwd changed — recreating session" + ); + agent.state.invalidate_channel(cid); + } + agent.state.channel_cwds.insert(*cid, session_cwd.clone()); + } + // // Core memory is delivered inside the system prompt the harness already // builds (system role for protocol >= 2, the `[System]` user-message @@ -1173,7 +1204,13 @@ pub async fn run_prompt_task( (sid.clone(), false) } else { // Create new session with model application. - match create_session_and_apply_model(&mut agent, &ctx, agent_core.as_deref()).await + match create_session_and_apply_model( + &mut agent, + &ctx, + &session_cwd, + agent_core.as_deref(), + ) + .await { Ok(sid) => { tracing::info!( @@ -1211,7 +1248,7 @@ pub async fn run_prompt_task( if let Some(sid) = &agent.state.heartbeat_session { (sid.clone(), false) } else { - match create_session_and_apply_model(&mut agent, &ctx, None).await { + match create_session_and_apply_model(&mut agent, &ctx, &session_cwd, None).await { Ok(sid) => { tracing::info!( target: "pool::session", @@ -1401,6 +1438,11 @@ pub async fn run_prompt_task( } else { None }; + let project_cwd = if session_cwd != ctx.cwd { + Some(session_cwd.as_str()) + } else { + None + }; let profile_lookup = fetch_prompt_profile_lookup(b, conversation_context.as_ref(), &ctx.rest_client).await; @@ -1427,6 +1469,7 @@ pub async fn run_prompt_task( agent_core: agent_core.as_deref(), channel_info: channel_info.as_ref(), conversation_context: conversation_context.as_ref(), + project_cwd, profile_lookup: profile_lookup.as_ref(), has_system_prompt_support: agent.protocol_version >= 2, base_prompt: ctx.base_prompt, @@ -1450,10 +1493,34 @@ pub async fn run_prompt_task( // 💬 — fire-and-forget so the prompt fires immediately. // The guard's cleanup (spawned on drop) removes 💬 after the turn completes. // A brief race where 💬 appears slightly after the agent starts is acceptable. + // Chats skip the reaction entirely: their UI carries live working + // indicators, and reaction emoji on the user's message reads as noise. + // The chat check resolves inside the spawned task (cache first, REST + // fallback for channels created after startup) so the prompt still fires + // immediately. if !reaction_ids.is_empty() { let rest = ctx.rest_client.clone(); let ids = reaction_ids.clone(); + let cached_is_chat = batch.as_ref().map(|b| { + ctx.channel_info + .get(&b.channel_id) + .map(|info| info.channel_type == "chat") + }); + let batch_channel_id = batch.as_ref().map(|b| b.channel_id); tokio::spawn(async move { + let is_chat = match cached_is_chat { + Some(Some(known)) => known, + Some(None) => match batch_channel_id { + Some(channel_id) => fetch_channel_info(channel_id, &rest) + .await + .is_some_and(|info| info.channel_type == "chat"), + None => false, + }, + None => false, + }; + if is_chat { + return; + } react_working(&rest, &ids).await; }); } @@ -1741,6 +1808,14 @@ pub async fn run_prompt_task( ) .await; + // The turn is done — release its completion marker before the + // best-effort title side prompt so the desktop's "Working" row + // clears while the title generates. + drop(_turn_guard); + if let PromptSource::Channel(cid) = &source { + maybe_emit_chat_title(&mut agent, &ctx, *cid, batch.as_ref()).await; + } + send_prompt_result( &result_tx, agent, @@ -1956,17 +2031,24 @@ async fn fetch_channel_info(channel_id: Uuid, rest: &RestClient) -> Option name = arr.get(1).and_then(|v| v.as_str()), Some("hidden") => is_hidden = true, Some("private") => is_private = true, + Some("t") => { + explicit_channel_type = + arr.get(1).and_then(|v| v.as_str()).map(str::to_string); + } _ => {} } } } - let channel_type = if is_hidden { + let channel_type = if let Some(channel_type) = explicit_channel_type { + channel_type + } else if is_hidden { "dm".to_string() } else if is_private { "private".to_string() @@ -1997,6 +2079,234 @@ async fn fetch_channel_info(channel_id: Uuid, rest: &RestClient) -> Option Option { + let channel_info = match ctx.channel_info.get(&channel_id) { + Some(ci) => Some(PromptChannelInfo { + name: ci.name.clone(), + channel_type: ci.channel_type.clone(), + }), + None => fetch_channel_info(channel_id, &ctx.rest_client).await, + }; + let is_chat = channel_info + .as_ref() + .map(|ci| ci.channel_type == "chat") + .unwrap_or(false); + let project_path = fetch_chat_project_path(channel_id, &ctx.rest_client).await; + if !is_chat && project_path.is_none() { + return None; + } + project_path.and_then(|path| usable_project_cwd(&path)) +} + +async fn fetch_chat_project_path(channel_id: Uuid, rest: &RestClient) -> Option { + use nostr::{Alphabet, SingleLetterTag}; + + const LEGACY_CHAT_METADATA_KIND: u16 = 30078; + const LEGACY_CHAT_METADATA_D_PREFIX: &str = "buzz:chat:"; + + let d_tag = SingleLetterTag::lowercase(Alphabet::D); + let channel_id_string = channel_id.to_string(); + let legacy_d = format!("{LEGACY_CHAT_METADATA_D_PREFIX}{channel_id_string}"); + let native_filter = nostr::Filter::new() + .kind(nostr::Kind::Custom( + buzz_core::kind::KIND_CHAT_METADATA as u16, + )) + .custom_tags(d_tag.clone(), [channel_id_string.as_str()]) + .limit(1); + let legacy_filter = nostr::Filter::new() + .kind(nostr::Kind::Custom(LEGACY_CHAT_METADATA_KIND)) + .custom_tags(d_tag, [legacy_d.as_str()]) + .limit(1); + + let metadata_path = fetch_with_retry(|| async { + match timeout( + CONTEXT_FETCH_TIMEOUT, + rest.query(&[native_filter.clone(), legacy_filter.clone()]), + ) + .await + { + Ok(Ok(json)) => parse_chat_project_path(json), + Ok(Err(e)) => { + tracing::debug!( + channel_id = %channel_id, + "chat project metadata fetch failed: {e} — will retry" + ); + None + } + Err(_) => { + tracing::debug!( + channel_id = %channel_id, + "chat project metadata fetch timed out — will retry" + ); + None + } + } + }) + .await; + if metadata_path.is_some() { + return metadata_path; + } + + if let Some(canvas_path) = fetch_chat_project_path_from_canvas(channel_id, rest).await { + return Some(canvas_path); + } + + fetch_chat_project_path_from_messages(channel_id, rest).await +} + +async fn fetch_chat_project_path_from_canvas( + channel_id: Uuid, + rest: &RestClient, +) -> Option { + use nostr::{Alphabet, SingleLetterTag}; + + let h_tag = SingleLetterTag::lowercase(Alphabet::H); + let channel_id_string = channel_id.to_string(); + let filter = nostr::Filter::new() + .kind(nostr::Kind::Custom(buzz_core::kind::KIND_CANVAS as u16)) + .custom_tags(h_tag, [channel_id_string.as_str()]) + .limit(1); + + fetch_with_retry(|| async { + match timeout( + CONTEXT_FETCH_TIMEOUT, + rest.query(std::slice::from_ref(&filter)), + ) + .await + { + Ok(Ok(json)) => parse_chat_project_path_from_canvas(json), + Ok(Err(e)) => { + tracing::debug!( + channel_id = %channel_id, + "chat project canvas fetch failed: {e} — will retry" + ); + None + } + Err(_) => { + tracing::debug!( + channel_id = %channel_id, + "chat project canvas fetch timed out — will retry" + ); + None + } + } + }) + .await +} + +fn parse_chat_project_path(events: serde_json::Value) -> Option { + events.as_array()?.iter().find_map(|event| { + event.get("tags")?.as_array()?.iter().find_map(|tag| { + let arr = tag.as_array()?; + if arr.first().and_then(|v| v.as_str()) == Some("project_path") { + arr.get(1) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + } else { + None + } + }) + }) +} + +fn parse_chat_project_path_from_canvas(events: serde_json::Value) -> Option { + events.as_array()?.iter().find_map(|event| { + event + .get("content") + .and_then(|value| value.as_str()) + .and_then(extract_project_path_from_canvas) + }) +} + +async fn fetch_chat_project_path_from_messages( + channel_id: Uuid, + rest: &RestClient, +) -> Option { + use nostr::{Alphabet, SingleLetterTag}; + + let h_tag = SingleLetterTag::lowercase(Alphabet::H); + let channel_id_string = channel_id.to_string(); + let filter = nostr::Filter::new() + .kinds([ + nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16), + nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_V2 as u16), + ]) + .custom_tags(h_tag, [channel_id_string.as_str()]) + .limit(50); + + fetch_with_retry(|| async { + match timeout( + CONTEXT_FETCH_TIMEOUT, + rest.query(std::slice::from_ref(&filter)), + ) + .await + { + Ok(Ok(json)) => parse_chat_project_path_from_messages(json), + Ok(Err(e)) => { + tracing::debug!( + channel_id = %channel_id, + "chat project setup message fetch failed: {e} — will retry" + ); + None + } + Err(_) => { + tracing::debug!( + channel_id = %channel_id, + "chat project setup message fetch timed out — will retry" + ); + None + } + } + }) + .await +} + +fn parse_chat_project_path_from_messages(events: serde_json::Value) -> Option { + events.as_array()?.iter().find_map(|event| { + event + .get("content") + .and_then(|value| value.as_str()) + .and_then(extract_project_path_from_canvas) + }) +} + +fn extract_project_path_from_canvas(content: &str) -> Option { + let mut in_project_setup = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.eq_ignore_ascii_case("Project setup") { + in_project_setup = true; + continue; + } + if in_project_setup && trimmed.is_empty() { + break; + } + if in_project_setup { + if let Some(path) = trimmed.strip_prefix("Folder:") { + let path = path.trim(); + if !path.is_empty() { + return Some(path.to_string()); + } + } + } + } + None +} + +fn usable_project_cwd(path: &str) -> Option { + let trimmed = path.trim(); + if trimmed.is_empty() || !trimmed.starts_with('/') || trimmed == "/" { + return None; + } + let canonical = std::fs::canonicalize(trimmed).ok()?; + if !canonical.is_dir() { + return None; + } + canonical.to_str().map(str::to_string) +} + /// Fetch conversation context (thread or DM) for a batch before prompting. /// /// Returns `None` if: @@ -2012,9 +2322,9 @@ async fn fetch_conversation_context( ctx: &PromptContext, ) -> Option { let limit = ctx.context_message_limit; - let is_dm = channel_info + let is_linear_chat = channel_info .as_ref() - .map(|ci| ci.channel_type == "dm") + .map(|ci| ci.channel_type == "dm" || ci.channel_type == "chat") .unwrap_or(false); // Check thread tags on the last event first — this applies to both @@ -2026,8 +2336,8 @@ async fn fetch_conversation_context( return fetch_thread_context(batch.channel_id, &root_id, limit, &ctx.rest_client).await; } - // DM non-reply: fetch recent conversation history. - if is_dm { + // DM/chat non-reply: fetch recent conversation history. + if is_linear_chat { return fetch_dm_context(batch.channel_id, limit, &ctx.rest_client).await; } @@ -2640,6 +2950,192 @@ async fn run_turn_liveness( } } +/// Idle/hard timeout for the chat-title side prompt. Titles are one short +/// completion; anything slower is abandoned rather than holding the agent. +const CHAT_TITLE_IDLE_TIMEOUT: Duration = Duration::from_secs(30); +const CHAT_TITLE_HARD_TIMEOUT: Duration = Duration::from_secs(45); + +/// Character cap for an emitted chat title. +const CHAT_TITLE_MAX_CHARS: usize = 60; + +/// Byte cap for the opening-message excerpt embedded in the title prompt. +const CHAT_TITLE_REQUEST_EXCERPT_LEN: usize = 1_500; + +/// After the first successful turn in a chat channel, generate a succinct +/// conversation title and emit it as a `chat_title` observer frame. The +/// desktop applies it to the chat's metadata (never overriding a manual +/// rename), so this stays fire-and-forget: every failure just logs. +/// +/// The title runs in a fresh session with NO MCP servers and NO system +/// prompt — a bare completion. The model has no tools, so it cannot post the +/// title (or anything else) into the channel. The throwaway session is left +/// to expire with the agent process; ACP has no portable close-session call. +async fn maybe_emit_chat_title( + agent: &mut OwnedAgent, + ctx: &PromptContext, + channel_id: Uuid, + batch: Option<&FlushBatch>, +) { + let is_chat = ctx + .channel_info + .get(&channel_id) + .is_some_and(|info| info.channel_type == "chat"); + if !is_chat { + return; + } + { + // Once per channel per process — even if generation fails, don't + // retry every turn (the desktop has a heuristic fallback). + let Ok(mut titled) = ctx.titled_channels.lock() else { + return; + }; + if !titled.insert(channel_id) { + return; + } + } + + let Some(request_text) = first_batch_message_excerpt(batch) else { + return; + }; + + // Mute the observer for the side prompt: its raw wire events would carry + // the just-completed turn's channel/turn context, and the desktop upserts + // `session/prompt` / message chunks by that context — the titling prompt + // would overwrite the turn's real prompt and assistant transcript items. + let observer = agent.acp.observer_handle(); + let observer_index = agent.acp.observer_agent_index().unwrap_or(0); + agent.acp.set_observer(None, observer_index); + let title = generate_chat_title(agent, ctx, &request_text).await; + agent.acp.set_observer(observer, observer_index); + + let Some(title) = title else { + return; + }; + + tracing::info!(target: "pool::title", "chat title for {channel_id}: {title}"); + agent.acp.observe( + "chat_title", + serde_json::json!({ "type": "chat_title", "title": title }), + ); +} + +/// Run the tool-less title side prompt and return the sanitized title. +/// +/// Must be called with the observer muted (see `maybe_emit_chat_title`) so +/// the side session's wire traffic never reaches transcripts. +async fn generate_chat_title( + agent: &mut OwnedAgent, + ctx: &PromptContext, + request_text: &str, +) -> Option { + let prompt = format!( + "You are naming a chat conversation. Name it exactly the way you \ + would name a git branch for this work — a few terse, concrete words \ + that identify the task — but written as plain words separated by \ + spaces instead of dashes. Reply with ONLY that name (2-5 words, no \ + quotes, no dashes, no trailing punctuation), capitalized like a \ + sentence.\n\nOpening message:\n{request_text}" + ); + + let session_id = match agent.acp.session_new(&ctx.cwd, Vec::new(), None).await { + Ok(session_id) => session_id, + Err(error) => { + if matches!(error, AcpError::AgentExited) { + agent.state.invalidate_all(); + } + tracing::warn!(target: "pool::title", "chat title session/new failed: {error}"); + return None; + } + }; + + agent.acp.begin_message_capture(); + let prompt_result = agent + .acp + .session_prompt_with_idle_timeout( + &session_id, + &prompt, + CHAT_TITLE_IDLE_TIMEOUT, + CHAT_TITLE_HARD_TIMEOUT, + ) + .await; + let captured = agent.acp.take_message_capture().unwrap_or_default(); + + if let Err(error) = prompt_result { + if matches!(error, AcpError::AgentExited) { + agent.state.invalidate_all(); + } + tracing::warn!(target: "pool::title", "chat title prompt failed: {error}"); + return None; + } + + let title = sanitize_chat_title(&captured); + if title.is_none() { + tracing::debug!(target: "pool::title", "chat title reply unusable: {captured:?}"); + } + title +} + +/// Extract the opening user message from the batch, truncated to a prompt +/// excerpt on a char boundary. +fn first_batch_message_excerpt(batch: Option<&FlushBatch>) -> Option { + let content = batch?.events.first()?.event.content.trim(); + if content.is_empty() { + return None; + } + let mut end = CHAT_TITLE_REQUEST_EXCERPT_LEN.min(content.len()); + while end > 0 && !content.is_char_boundary(end) { + end -= 1; + } + Some(content[..end].to_string()) +} + +/// Normalize a model's title reply into a clean single-line title, or `None` +/// when nothing usable survives. +fn sanitize_chat_title(raw: &str) -> Option { + // Models occasionally preface with "Title:" or wrap in quotes/markdown; + // take the first non-empty line and strip that framing. + let line = raw.lines().map(str::trim).find(|line| !line.is_empty())?; + let mut title = line.trim_start_matches("Title:").trim(); + title = title.trim_matches(|c| matches!(c, '"' | '\'' | '“' | '”' | '*' | '`' | '#')); + let mut cleaned = title.trim().to_string(); + if cleaned.chars().count() > CHAT_TITLE_MAX_CHARS { + let last_space = cleaned + .char_indices() + .take(CHAT_TITLE_MAX_CHARS) + .filter(|(_, c)| c.is_whitespace()) + .map(|(i, _)| i) + .last(); + let hard_cut = cleaned + .char_indices() + .nth(CHAT_TITLE_MAX_CHARS) + .map(|(i, _)| i) + .unwrap_or(cleaned.len()); + cleaned.truncate(last_space.unwrap_or(hard_cut)); + } + let mut cleaned = cleaned + .trim_end_matches(['.', ',', ';', ':', '!', '?', '-']) + .trim() + .to_string(); + // Despite the prompt, models sometimes answer with the literal branch + // slug ("fix-panel-width", "kenny/dictation-support"). A single dashed/ + // slashed token becomes spaced words with a leading capital. + if !cleaned.contains(char::is_whitespace) && cleaned.contains(['-', '_', '/']) { + let mut words = cleaned + .split(['-', '_', '/']) + .filter(|word| !word.is_empty()) + .collect::>() + .join(" "); + if let Some(first) = words.get_mut(0..1) { + first.make_ascii_uppercase(); + } + cleaned = words; + } + if cleaned.chars().count() < 3 { + return None; + } + Some(cleaned) +} + // Emits a `turn_completed` observer event on drop, covering ALL exit paths // (success, error, timeout, cancel, panic) from `run_prompt_task`. Captures // observer handle and metadata at creation time so it remains valid even after @@ -3034,6 +3530,60 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag}; use serde_json::json; + #[test] + fn test_sanitize_chat_title_strips_model_framing() { + assert_eq!( + sanitize_chat_title("Title: \"Fix flaky Playwright tests\"\n"), + Some("Fix flaky Playwright tests".to_string()) + ); + assert_eq!( + sanitize_chat_title("**Debug relay disconnects.**"), + Some("Debug relay disconnects".to_string()) + ); + assert_eq!( + sanitize_chat_title("\n\n Rename deploy workflow \nExtra explanation line"), + Some("Rename deploy workflow".to_string()) + ); + } + + #[test] + fn test_sanitize_chat_title_caps_on_word_boundary() { + let long = + "a very long title that keeps going well past the sixty character cap for titles"; + let title = sanitize_chat_title(long).expect("title"); + assert!(title.chars().count() <= CHAT_TITLE_MAX_CHARS, "{title}"); + assert!(!title.ends_with(' ')); + assert!(long.starts_with(&title)); + } + + #[test] + fn test_sanitize_chat_title_despaces_branch_slugs() { + assert_eq!( + sanitize_chat_title("fix-panel-width"), + Some("Fix panel width".to_string()) + ); + assert_eq!( + sanitize_chat_title("kenny/dictation-support"), + Some("Kenny dictation support".to_string()) + ); + assert_eq!( + sanitize_chat_title("relay_reconnect_backoff"), + Some("Relay reconnect backoff".to_string()) + ); + // Titles that already have spaces keep interior dashes untouched. + assert_eq!( + sanitize_chat_title("Fix e2e re-run flake"), + Some("Fix e2e re-run flake".to_string()) + ); + } + + #[test] + fn test_sanitize_chat_title_rejects_empty_and_tiny() { + assert_eq!(sanitize_chat_title(""), None); + assert_eq!(sanitize_chat_title(" \n \n"), None); + assert_eq!(sanitize_chat_title("ok"), None); + } + // These pin the initial_message dispatch path (run_prompt_task, ~line 855): // a legacy agent WITH a base_prompt must get [Base] prepended to the user // message. This is the exact regression that shipped in the round-2 bug. @@ -3129,6 +3679,58 @@ mod tests { assert!(workspace_section("").is_none()); } + #[test] + fn test_parse_chat_project_path_extracts_trimmed_tag() { + let path = parse_chat_project_path(json!([ + { + "tags": [ + ["d", "chat-id"], + ["project_path", " /Users/me/Development/sprout "] + ] + } + ])); + + assert_eq!(path.as_deref(), Some("/Users/me/Development/sprout")); + } + + #[test] + fn test_parse_chat_project_path_from_canvas_extracts_folder_line() { + let path = parse_chat_project_path_from_canvas(json!([ + { + "content": "Project setup\nProject: Buzz\nFolder: /Users/me/Development/sprout\n\n# Notes" + } + ])); + + assert_eq!(path.as_deref(), Some("/Users/me/Development/sprout")); + } + + #[test] + fn test_parse_chat_project_path_from_messages_extracts_folder_line() { + let path = parse_chat_project_path_from_messages(json!([ + { + "kind": 9, + "content": "hello" + }, + { + "kind": 9, + "content": "Project setup\nProject: Buzz\nFolder: /Users/me/Development/sprout\nAgent: Fizz" + } + ])); + + assert_eq!(path.as_deref(), Some("/Users/me/Development/sprout")); + } + + #[test] + fn test_extract_project_path_from_canvas_requires_project_setup_block() { + assert!(extract_project_path_from_canvas("Folder: /tmp/nope").is_none()); + } + + #[test] + fn test_usable_project_cwd_rejects_relative_and_root_paths() { + assert!(usable_project_cwd("relative/path").is_none()); + assert!(usable_project_cwd("/").is_none()); + } + #[test] fn test_with_core_appends_below_framed() { let framed = with_core( @@ -3536,6 +4138,8 @@ mod tests { let mut s = SessionState::default(); s.sessions.insert(ch_a, "sess-a".into()); s.sessions.insert(ch_b, "sess-b".into()); + s.channel_cwds.insert(ch_a, "/tmp/a".into()); + s.channel_cwds.insert(ch_b, "/tmp/b".into()); s.turn_counts.insert(ch_a, 5); s.turn_counts.insert(ch_b, 3); s.core_sections.insert(ch_a, "core-a".into()); @@ -3577,6 +4181,7 @@ mod tests { ); assert_eq!(s.sessions.get(&ch_a).unwrap(), "sess-a"); + assert_eq!(s.channel_cwds.get(&ch_a).unwrap(), "/tmp/a"); assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5); assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a"); assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); @@ -3621,6 +4226,7 @@ mod tests { s.invalidate_all(); assert!(s.sessions.is_empty()); + assert!(s.channel_cwds.is_empty()); assert!(s.turn_counts.is_empty()); assert!(s.core_sections.is_empty()); assert!(s.heartbeat_session.is_none()); @@ -3647,6 +4253,7 @@ mod tests { let mut s = SessionState::default(); s.invalidate_all(); // should not panic assert!(s.sessions.is_empty()); + assert!(s.channel_cwds.is_empty()); assert!(s.turn_counts.is_empty()); assert!(s.core_sections.is_empty()); } @@ -3675,6 +4282,7 @@ mod tests { assert!(!s.invalidate_channel(&ghost)); // Nothing changed. assert_eq!(s.sessions.len(), 2); + assert_eq!(s.channel_cwds.len(), 2); assert_eq!(s.turn_counts.len(), 2); } diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 5a82ad6845..b0f08db022 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1088,8 +1088,10 @@ pub(crate) fn format_event_block( /// top level. fn append_reply_instruction(s: &mut String, event_id: &str) { s.push_str(&format!( - "\nIMPORTANT: For ordinary replies in this turn, use `--reply-to {event_id}` \ - on `buzz messages send` so the conversation stays threaded. \ + "\nIMPORTANT: For ordinary replies in this turn, use \ + `buzz messages send --channel --reply-to {event_id} --content \"...\"` \ + so the conversation stays threaded. Message text is not positional; always pass it with \ + `--content`, or pipe it with `--content -` for shell-sensitive text. \ If the human explicitly asks for a channel-root, top-level, \ or broadcast post, send that message without `--reply-to`. \ If the requested destination is ambiguous, ask before sending." @@ -1104,13 +1106,39 @@ fn append_reply_instruction(s: &mut String, event_id: &str) { fn append_new_thread_reply_instruction(s: &mut String, event_id: &str) { s.push_str(&format!( "\nIMPORTANT: This is a new top-level message. For ordinary replies in \ - this turn, use `--reply-to {event_id}` on `buzz messages send` — the \ - triggering message is the thread root. Do NOT reply into any other \ + this turn, use \ + `buzz messages send --channel --reply-to {event_id} --content \"...\"` — \ + the triggering message is the thread root. Message text is not positional; always pass it with \ + `--content`, or pipe it with `--content -` for shell-sensitive text. Do NOT reply into any other \ (older) thread. If the human explicitly asks for a channel-root, \ top-level, or broadcast post, send that message without `--reply-to`." )); } +fn append_chat_final_answer_instruction(s: &mut String) { + s.push_str( + "\nIMPORTANT: This is the dedicated chat interface. The user already sees \ + your tool calls, searches, command output, and activity markers in the chat UI. \ + Send exactly one human-facing final answer with `buzz messages send` when \ + the turn is complete. Do not send progress, status, or self-narration \ + messages like \"let me check\", \"now I have\", \"I found\", \"I sent\", \ + or \"let me reply\".", + ); +} + +fn append_chat_project_workspace_instruction(s: &mut String, cwd: &str) { + let cwd = cwd.trim(); + if cwd.is_empty() || cwd == "/" { + return; + } + s.push_str(&format!( + "\nProject folder: {cwd}\n\ + IMPORTANT: Use the project folder above as the working project for this chat. \ + When inspecting or editing files, start from that folder. Do not clone a fresh \ + copy into `REPOS/` or use the default agent workspace unless the human asks for that." + )); +} + /// 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 @@ -1174,7 +1202,8 @@ fn format_context_hints( channel_id: Uuid, channel_info: Option<&PromptChannelInfo>, thread_tags: &ThreadTags, - is_dm: bool, + linear_scope: Option<&str>, + project_cwd: Option<&str>, has_conversation_context: bool, reply_anchor: Option<&str>, ) -> String { @@ -1183,12 +1212,13 @@ fn format_context_hints( None => channel_id.to_string(), }; - // DM check comes first — a DM reply has both thread tags AND is_dm=true, - // and the scope should be "dm" (not "thread") because the agent is in a DM. - if is_dm { + // Linear chat check comes first — a DM/chat reply has both thread tags and + // linear_scope, and the scope should be the conversation type rather than + // "thread". + if let Some(scope) = linear_scope { let is_reply = thread_tags.root_event_id.is_some(); - // DM replies use thread command because /messages excludes thread replies. - // DM non-replies use get for recent conversation. + // Linear replies use thread command because /messages excludes thread + // replies. Linear non-replies use get for recent conversation. let ctx_hint = if has_conversation_context && is_reply { "Thread context included below. Use `buzz messages thread --channel --event ` for full history if truncated." } else if has_conversation_context { @@ -1200,11 +1230,11 @@ fn format_context_hints( }; let mut s = format!( "[Context]\n\ - Scope: dm\n\ + Scope: {scope}\n\ Channel: {channel_display}\n\ {ctx_hint}" ); - // If this is a DM reply, include thread structural info as supplementary. + // If this is a linear reply, include thread structural info as supplementary. if let Some(ref root) = thread_tags.root_event_id { s.push_str(&format!("\nThread root: {root}")); if let Some(ref parent) = thread_tags.parent_event_id { @@ -1216,6 +1246,12 @@ fn format_context_hints( append_reply_instruction(&mut s, event_id); } } + if scope == "chat" { + if let Some(cwd) = project_cwd { + append_chat_project_workspace_instruction(&mut s, cwd); + } + append_chat_final_answer_instruction(&mut s); + } s } else if let Some(ref root) = thread_tags.root_event_id { let ctx_hint = if has_conversation_context { @@ -1294,6 +1330,7 @@ pub struct FormatPromptArgs<'a> { pub agent_core: Option<&'a str>, pub channel_info: Option<&'a PromptChannelInfo>, pub conversation_context: Option<&'a ConversationContext>, + pub project_cwd: Option<&'a str>, pub profile_lookup: Option<&'a PromptProfileLookup>, /// When true, base_prompt and system_prompt are delivered via the system /// role (session/new) and omitted from the user message. When false @@ -1347,10 +1384,17 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec = Vec::with_capacity(7); @@ -1383,9 +1427,10 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec) -> Vec --reply-to {root_id} --content" + )), + "reply instruction should show the correct --content send syntax" + ); assert!( prompt.contains("For ordinary replies in this turn"), "channel thread reply should describe reply-to as the default" @@ -3837,6 +3968,12 @@ mod tests { prompt.contains(&format!("--reply-to {event_id}")), "top-level human message should anchor a new thread at the triggering event" ); + assert!( + prompt.contains(&format!( + "buzz messages send --channel --reply-to {event_id} --content" + )), + "new-thread instruction should show the correct --content send syntax" + ); assert!( prompt.contains("new top-level message"), "top-level human message should use the new-thread instruction" diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 0f26fc5d46..be1187d647 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -108,6 +108,7 @@ fn merge_discovered_channels( let mut is_hidden = false; let mut is_private = false; let mut is_archived = false; + let mut explicit_channel_type = None; for tag in tags { if let Some(arr) = tag.as_array() { match arr.first().and_then(|v| v.as_str()) { @@ -115,6 +116,10 @@ fn merge_discovered_channels( Some("name") => name = arr.get(1).and_then(|v| v.as_str()), Some("hidden") => is_hidden = true, Some("private") => is_private = true, + Some("t") => { + explicit_channel_type = + arr.get(1).and_then(|v| v.as_str()).map(str::to_string); + } Some("archived") => { is_archived = arr.get(1).and_then(|v| v.as_str()) == Some("true") } @@ -130,7 +135,9 @@ fn merge_discovered_channels( } let ch_name = name.unwrap_or("unknown").to_string(); // DMs have the "hidden" tag; private channels have "private". - let ch_type = if is_hidden { + let ch_type = if let Some(channel_type) = explicit_channel_type { + channel_type + } else if is_hidden { "dm".to_string() } else if is_private { "private".to_string() diff --git a/crates/buzz-core/src/channel.rs b/crates/buzz-core/src/channel.rs index 0bc130f263..b5631952d3 100644 --- a/crates/buzz-core/src/channel.rs +++ b/crates/buzz-core/src/channel.rs @@ -53,6 +53,8 @@ pub enum ChannelType { Forum, /// Direct message conversation. Dm, + /// Private AI chat conversation. + Chat, /// Internal workflow execution channel. Workflow, } @@ -64,6 +66,7 @@ impl ChannelType { Self::Stream => "stream", Self::Forum => "forum", Self::Dm => "dm", + Self::Chat => "chat", Self::Workflow => "workflow", } } @@ -83,6 +86,7 @@ impl FromStr for ChannelType { "stream" => Ok(Self::Stream), "forum" => Ok(Self::Forum), "dm" => Ok(Self::Dm), + "chat" => Ok(Self::Chat), "workflow" => Ok(Self::Workflow), other => Err(format!("unknown channel type: {other:?}")), } @@ -167,3 +171,22 @@ impl FromStr for MemberRole { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn channel_type_chat_round_trips() { + assert_eq!(ChannelType::from_str("chat"), Ok(ChannelType::Chat)); + assert_eq!(ChannelType::Chat.as_str(), "chat"); + assert_eq!(ChannelType::Chat.to_string(), "chat"); + } + + #[test] + fn channel_type_rejects_unknown_values() { + let err = ChannelType::from_str("ai-chat").unwrap_err(); + assert!(err.contains("unknown channel type")); + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index ae9e7ef13f..b8459e61e6 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -263,6 +263,12 @@ pub const KIND_MESH_LLM_RELAY_STATUS: u32 = 30621; /// `hidden_at` per viewer; this is the only Nostr-visible projection of it. pub const KIND_DM_VISIBILITY: u32 = 30622; +/// Buzz Chat metadata (parameterized replaceable, d=chat channel UUID). +/// +/// Channel-scoped with an `h` tag so private chat titles, default agent +/// pointers, template ids, and source refs stay behind channel membership. +pub const KIND_CHAT_METADATA: u32 = 30623; + /// Lower bound of the NIP-33 parameterized replaceable range (30000–39999). pub const PARAM_REPLACEABLE_KIND_MIN: u32 = 30000; /// Upper bound of the NIP-33 parameterized replaceable range (30000–39999). @@ -530,6 +536,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PRESENCE_SNAPSHOT, KIND_MESH_LLM_RELAY_STATUS, KIND_DM_VISIBILITY, + KIND_CHAT_METADATA, KIND_DM_OPEN, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, @@ -677,6 +684,7 @@ const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MESH_LLM_RELAY_STATUS)); // 30621 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_CHAT_METADATA)); // 30623 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999 diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 5c38e5d1c5..8477837cd5 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -822,13 +822,14 @@ pub async fn get_accessible_channels( WHERE c.community_id = $1 AND c.deleted_at IS NULL {membership_clause} AND (c.channel_type != 'dm' OR cm.hidden_at IS NULL) + AND (c.channel_type != 'chat' OR cm.channel_id IS NOT NULL) "# ); let sql = if visibility_filter.is_some() { - format!("{base} AND c.visibility::text = $3\n ORDER BY array_position(ARRAY['stream','forum','dm']::text[], c.channel_type::text), c.name\n LIMIT 1000") + format!("{base} AND c.visibility::text = $3\n ORDER BY array_position(ARRAY['stream','forum','dm','chat']::text[], c.channel_type::text), c.name\n LIMIT 1000") } else { - format!("{base} ORDER BY array_position(ARRAY['stream','forum','dm']::text[], c.channel_type::text), c.name\n LIMIT 1000") + format!("{base} ORDER BY array_position(ARRAY['stream','forum','dm','chat']::text[], c.channel_type::text), c.name\n LIMIT 1000") }; let query = sqlx::query(sqlx::AssertSqlSafe(sql)) @@ -1371,7 +1372,7 @@ mod tests { use crate::user::{ensure_user, set_agent_owner}; use nostr::Keys; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_pool() -> PgPool { PgPool::connect(TEST_DB_URL) diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 69cb48df89..00e95a2745 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -21,7 +21,7 @@ mod tests { use super::*; use std::collections::BTreeSet; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ConstraintKind { @@ -471,7 +471,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 5); + assert_eq!(migrations.len(), 6); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -532,6 +532,17 @@ mod tests { assert!(migrations[4].sql.as_str().contains("search_tsv")); assert!(migrations[4].sql.as_str().contains("44200")); assert!(!migrations[0].sql.as_str().contains("44200")); + + // Chat channels are also additive. Existing databases receive the enum + // value from migration 6; 0001 stays immutable for brownfield checksum + // stability. + assert_eq!(migrations[5].version, 6); + assert!(migrations[5] + .sql + .as_str() + .contains("ALTER TYPE channel_type ADD VALUE")); + assert!(migrations[5].sql.as_str().contains("'chat'")); + assert!(!migrations[0].sql.as_str().contains("'chat'")); } #[test] diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 9abb5948f9..ea55f92099 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -14,16 +14,16 @@ use buzz_core::kind::{ event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, - KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, - KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, - KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, - KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, - KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, - KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, - KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS, KIND_MUTE_LIST, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, + KIND_CHAT_METADATA, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, + KIND_DM_OPEN, KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, + KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, + KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, + KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, + KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, + KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS, + KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, @@ -184,6 +184,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), @@ -400,6 +401,7 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_STREAM_REMINDER | KIND_STREAM_MESSAGE_DIFF | KIND_CANVAS + | KIND_CHAT_METADATA | KIND_FORUM_POST | KIND_FORUM_VOTE | KIND_FORUM_COMMENT @@ -2407,6 +2409,7 @@ mod tests { KIND_TEAM, KIND_MANAGED_AGENT, KIND_AGENT_TURN_METRIC, + KIND_CHAT_METADATA, ]; for kind in migrated { assert!( diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 5c898ea1eb..0afb5dfb62 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -964,10 +964,13 @@ pub async fn emit_group_discovery_events( // making channel visibility self-describing for clients. tags.push(Tag::parse(["public"])?); } - // NIP-29 hidden tag: hint to clients not to show DMs in public group lists. + // NIP-29 hidden tag: hint to clients not to show DMs/chats in public group lists. // Not a security boundary — access control is handled by channel-scoped storage. - if channel.channel_type == "dm" { + if channel.channel_type == "dm" || channel.channel_type == "chat" { tags.push(Tag::parse(["hidden"])?); + } + + if channel.channel_type == "dm" { // Include participant pubkeys in kind:39000 for DMs so clients can // resolve display names without a separate kind:39002 fetch. for m in &members { diff --git a/desktop/package.json b/desktop/package.json index f20f345614..0075c15da7 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -43,6 +43,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", + "@shadcn/react": "0.2.0", "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.168.10", "@tanstack/react-virtual": "^3.14.2", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index a1461f52c5..d5d0d53d44 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -76,6 +76,8 @@ export default defineConfig({ "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", "**/channel-sort.spec.ts", + "**/chats-first-message.spec.ts", + "**/chats-switch-repro.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index ed96c50d1e..ce697c5324 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -164,7 +164,9 @@ const overrides = new Map([ // uid-keyed lockfile path + behavioral tests add ~303 lines. Load-bearing // security fix for the lost-update race that stranded agent keys. ["src-tauri/src/secret_store.rs", 1043], - ["src-tauri/src/app_state.rs", 1033], + // +3: connect_timeout on the shared reqwest client (hang-proofing relay + // bridge calls against dead VPN tunnels that blackhole packets). + ["src-tauri/src/app_state.rs", 1036], // multi-slot splitting + no-op suppression (#1309): the ReadStateManager // class grew from ~700 lines to ~1019 with the addition of // splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots, diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index dca627fe6e..fb7a11334b 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -88,6 +88,9 @@ pub fn build_app_state() -> AppState { keys: Mutex::new(keys), http_client: reqwest::Client::builder() .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) + // Connect-phase only; request deadlines are per-request (media + // streaming stays unbounded, relay.rs sets RELAY_REQUEST_TIMEOUT). + .connect_timeout(std::time::Duration::from_secs(10)) .pool_idle_timeout(std::time::Duration::from_secs(10)) .pool_max_idle_per_host(1) .build() diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 59ab3b25a9..ac4b1e2a09 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -12,6 +12,19 @@ use crate::{ #[tauri::command] pub async fn get_channels(state: State<'_, AppState>) -> Result, String> { + get_channels_internal(state, true).await +} + +pub(super) async fn get_channels_including_chats( + state: State<'_, AppState>, +) -> Result, String> { + get_channels_internal(state, false).await +} + +async fn get_channels_internal( + state: State<'_, AppState>, + exclude_chat_channels: bool, +) -> Result, String> { let _profile_start = std::time::Instant::now(); let my_pubkey = { let keys = state.keys.lock().map_err(|e| e.to_string())?; @@ -251,6 +264,14 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result if !hidden_dms.is_empty() { channels.retain(|c| c.channel_type != "dm" || !hidden_dms.contains(&c.id)); } + if exclude_chat_channels { + let chat_channel_ids = super::chats::fetch_chat_metadata_channel_ids(&state) + .await + .unwrap_or_default(); + if !chat_channel_ids.is_empty() { + channels.retain(|c| c.channel_type != "chat" && !chat_channel_ids.contains(&c.id)); + } + } #[cfg(debug_assertions)] { diff --git a/desktop/src-tauri/src/commands/chats.rs b/desktop/src-tauri/src/commands/chats.rs new file mode 100644 index 0000000000..b70d3e2f03 --- /dev/null +++ b/desktop/src-tauri/src/commands/chats.rs @@ -0,0 +1,700 @@ +use nostr::{EventBuilder, Kind, Tag}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use tauri::State; +use uuid::Uuid; + +use crate::{ + app_state::AppState, + events, + models::{ChannelInfo, SendChannelMessageResponse}, + nostr_convert, + relay::{query_relay, submit_event}, +}; + +const LEGACY_CHAT_METADATA_KIND: u16 = 30078; +const LEGACY_CHAT_METADATA_D_PREFIX: &str = "buzz:chat:"; +const MAX_CONTENT_BYTES: usize = 64 * 1024; + +fn tag(parts: Vec<&str>) -> Result { + Tag::parse(parts).map_err(|e| format!("invalid tag: {e}")) +} + +fn check_pubkey(pubkey: &str) -> Result<(), String> { + if !pubkey.chars().all(|c| c.is_ascii_hexdigit()) || pubkey.len() != 64 { + return Err(format!( + "pubkey must be a 64-character hex string (got {} chars)", + pubkey.len() + )); + } + Ok(()) +} + +fn check_content(content: &str) -> Result<(), String> { + if content.len() > MAX_CONTENT_BYTES { + return Err(format!( + "content exceeds maximum size of {MAX_CONTENT_BYTES} bytes (got {})", + content.len() + )); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn build_chat_metadata_tags( + channel_id: Uuid, + legacy: bool, + title: Option<&str>, + default_agent_pubkey: Option<&str>, + template_id: Option<&str>, + project_id: Option<&str>, + project_name: Option<&str>, + project_path: Option<&str>, + project_template_id: Option<&str>, + source_channel_id: Option<&str>, + source_event_id: Option<&str>, + source_thread_root_id: Option<&str>, +) -> Result, String> { + let channel_id_string = channel_id.to_string(); + let mut tags = if legacy { + let d_tag = format!("{LEGACY_CHAT_METADATA_D_PREFIX}{channel_id_string}"); + vec![ + tag(vec!["d", &d_tag])?, + tag(vec!["chat_h", &channel_id_string])?, + ] + } else { + vec![ + tag(vec!["d", &channel_id_string])?, + tag(vec!["h", &channel_id_string])?, + ] + }; + + if let Some(title) = title.map(str::trim).filter(|value| !value.is_empty()) { + tags.push(tag(vec!["title", title])?); + } + if let Some(pubkey) = default_agent_pubkey + .map(str::trim) + .filter(|value| !value.is_empty()) + { + check_pubkey(pubkey)?; + tags.push(tag(vec!["default_agent", &pubkey.to_ascii_lowercase()])?); + } + if let Some(template_id) = template_id.map(str::trim).filter(|value| !value.is_empty()) { + tags.push(tag(vec!["template", template_id])?); + } + if let Some(project_id) = project_id.map(str::trim).filter(|value| !value.is_empty()) { + tags.push(tag(vec!["project_id", project_id])?); + } + if let Some(project_name) = project_name + .map(str::trim) + .filter(|value| !value.is_empty()) + { + tags.push(tag(vec!["project_name", project_name])?); + } + if let Some(project_path) = project_path + .map(str::trim) + .filter(|value| !value.is_empty()) + { + tags.push(tag(vec!["project_path", project_path])?); + } + if let Some(project_template_id) = project_template_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + tags.push(tag(vec!["project_template", project_template_id])?); + } + if let Some(source_channel_id) = source_channel_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + tags.push(tag(vec!["source_h", source_channel_id])?); + } + if let Some(source_event_id) = source_event_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + tags.push(tag(vec!["source_e", source_event_id])?); + } + if let Some(source_thread_root_id) = source_thread_root_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + tags.push(tag(vec!["source_root", source_thread_root_id])?); + } + + Ok(tags) +} + +#[allow(clippy::too_many_arguments)] +fn build_chat_metadata( + channel_id: Uuid, + title: Option<&str>, + default_agent_pubkey: Option<&str>, + template_id: Option<&str>, + project_id: Option<&str>, + project_name: Option<&str>, + project_path: Option<&str>, + project_template_id: Option<&str>, + source_channel_id: Option<&str>, + source_event_id: Option<&str>, + source_thread_root_id: Option<&str>, +) -> Result { + let tags = build_chat_metadata_tags( + channel_id, + false, + title, + default_agent_pubkey, + template_id, + project_id, + project_name, + project_path, + project_template_id, + source_channel_id, + source_event_id, + source_thread_root_id, + )?; + Ok(EventBuilder::new( + Kind::Custom(buzz_core_pkg::kind::KIND_CHAT_METADATA as u16), + "", + ) + .tags(tags)) +} + +#[allow(clippy::too_many_arguments)] +fn build_legacy_chat_metadata( + channel_id: Uuid, + title: Option<&str>, + default_agent_pubkey: Option<&str>, + template_id: Option<&str>, + project_id: Option<&str>, + project_name: Option<&str>, + project_path: Option<&str>, + project_template_id: Option<&str>, + source_channel_id: Option<&str>, + source_event_id: Option<&str>, + source_thread_root_id: Option<&str>, +) -> Result { + let tags = build_chat_metadata_tags( + channel_id, + true, + title, + default_agent_pubkey, + template_id, + project_id, + project_name, + project_path, + project_template_id, + source_channel_id, + source_event_id, + source_thread_root_id, + )?; + Ok(EventBuilder::new(Kind::Custom(LEGACY_CHAT_METADATA_KIND), "").tags(tags)) +} + +fn build_chat_context_message( + channel_id: Uuid, + content: &str, + source_channel_id: Option<&str>, + source_event_id: Option<&str>, + source_thread_root_id: Option<&str>, +) -> Result { + check_content(content)?; + let channel_id_string = channel_id.to_string(); + let mut tags = vec![ + tag(vec!["h", &channel_id_string])?, + tag(vec!["chat_context", "source"])?, + ]; + + if let Some(source_channel_id) = source_channel_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + tags.push(tag(vec!["source_h", source_channel_id])?); + } + if let Some(source_event_id) = source_event_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + tags.push(tag(vec!["source_e", source_event_id])?); + } + if let Some(source_thread_root_id) = source_thread_root_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + tags.push(tag(vec!["source_root", source_thread_root_id])?); + } + + Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatSourceInput { + #[serde(default)] + pub channel_id: Option, + #[serde(default)] + pub event_id: Option, + #[serde(default)] + pub thread_root_id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateChatInput { + #[serde(default)] + pub title: Option, + #[serde(default)] + pub default_agent_pubkey: Option, + #[serde(default)] + pub template_id: Option, + #[serde(default)] + pub project_id: Option, + #[serde(default)] + pub project_name: Option, + #[serde(default)] + pub project_path: Option, + #[serde(default)] + pub project_template_id: Option, + #[serde(default)] + pub source: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateChatMetadataInput { + pub channel_id: String, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub default_agent_pubkey: Option, + #[serde(default)] + pub template_id: Option, + #[serde(default)] + pub project_id: Option, + #[serde(default)] + pub project_name: Option, + #[serde(default)] + pub project_path: Option, + #[serde(default)] + pub project_template_id: Option, + #[serde(default)] + pub source: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendChatContextMessageInput { + pub channel_id: String, + pub content: String, + #[serde(default)] + pub source: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub struct ChatMetadataInfo { + pub channel_id: String, + pub author_pubkey: String, + pub title: Option, + pub default_agent_pubkey: Option, + pub template_id: Option, + pub project_id: Option, + pub project_name: Option, + pub project_path: Option, + pub project_template_id: Option, + pub source_channel_id: Option, + pub source_event_id: Option, + pub source_thread_root_id: Option, + pub updated_at: i64, +} + +fn trimmed(value: Option<&String>) -> Option<&str> { + value + .map(String::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) +} + +fn source_channel_id(source: Option<&ChatSourceInput>) -> Option<&str> { + source.and_then(|source| trimmed(source.channel_id.as_ref())) +} + +fn source_event_id(source: Option<&ChatSourceInput>) -> Option<&str> { + source.and_then(|source| trimmed(source.event_id.as_ref())) +} + +fn source_thread_root_id(source: Option<&ChatSourceInput>) -> Option<&str> { + source.and_then(|source| trimmed(source.thread_root_id.as_ref())) +} + +fn first_tag_value(event: &nostr::Event, name: &str) -> Option { + event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 && parts[0] == name).then(|| parts[1].clone()) + }) +} + +fn legacy_chat_metadata_d_tag(channel_id: &str) -> String { + format!("{LEGACY_CHAT_METADATA_D_PREFIX}{channel_id}") +} + +fn chat_metadata_from_event(event: &nostr::Event) -> Option { + let channel_id = if event.kind.as_u16() == LEGACY_CHAT_METADATA_KIND { + first_tag_value(event, "chat_h").or_else(|| { + first_tag_value(event, "d").and_then(|d| { + d.strip_prefix(LEGACY_CHAT_METADATA_D_PREFIX) + .map(str::to_string) + }) + })? + } else { + first_tag_value(event, "d")? + }; + Some(ChatMetadataInfo { + channel_id, + author_pubkey: event.pubkey.to_hex(), + title: first_tag_value(event, "title"), + default_agent_pubkey: first_tag_value(event, "default_agent"), + template_id: first_tag_value(event, "template"), + project_id: first_tag_value(event, "project_id"), + project_name: first_tag_value(event, "project_name"), + project_path: first_tag_value(event, "project_path"), + project_template_id: first_tag_value(event, "project_template"), + source_channel_id: first_tag_value(event, "source_h"), + source_event_id: first_tag_value(event, "source_e"), + source_thread_root_id: first_tag_value(event, "source_root"), + updated_at: event.created_at.as_secs() as i64, + }) +} + +fn is_unsupported_chat_channel_type_error(error: &str) -> bool { + error.contains("invalid channel_type: chat") +} + +fn is_unknown_event_kind_error(error: &str) -> bool { + error.contains("restricted: unknown event kind") +} + +fn current_pubkey(state: &AppState) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + Ok(keys.public_key().to_hex()) +} + +#[allow(clippy::too_many_arguments)] +async fn submit_chat_metadata( + state: &AppState, + channel_id: uuid::Uuid, + title: Option<&str>, + default_agent_pubkey: Option<&str>, + template_id: Option<&str>, + project_id: Option<&str>, + project_name: Option<&str>, + project_path: Option<&str>, + project_template_id: Option<&str>, + source_channel_id: Option<&str>, + source_event_id: Option<&str>, + source_thread_root_id: Option<&str>, +) -> Result<(), String> { + let metadata = build_chat_metadata( + channel_id, + title, + default_agent_pubkey, + template_id, + project_id, + project_name, + project_path, + project_template_id, + source_channel_id, + source_event_id, + source_thread_root_id, + )?; + match submit_event(metadata, state).await { + Ok(_) => Ok(()), + Err(error) if is_unknown_event_kind_error(&error) => { + eprintln!( + "buzz-desktop: relay does not support kind:30623 yet; writing legacy kind:30078 chat metadata" + ); + let legacy_metadata = build_legacy_chat_metadata( + channel_id, + title, + default_agent_pubkey, + template_id, + project_id, + project_name, + project_path, + project_template_id, + source_channel_id, + source_event_id, + source_thread_root_id, + )?; + submit_event(legacy_metadata, state) + .await + .map(|_| ()) + .map_err(|legacy_error| { + format!("native chat metadata unsupported ({error}); legacy metadata failed: {legacy_error}") + }) + } + Err(error) => Err(error), + } +} + +async fn fetch_chat_metadata_infos(state: &AppState) -> Result, String> { + let native_events = query_relay( + state, + &[serde_json::json!({ + "kinds": [buzz_core_pkg::kind::KIND_CHAT_METADATA], + "limit": 500 + })], + ) + .await + .unwrap_or_default(); + let legacy_events = query_relay( + state, + &[serde_json::json!({ + "kinds": [LEGACY_CHAT_METADATA_KIND], + "authors": [current_pubkey(state)?], + "limit": 5000 + })], + ) + .await + .unwrap_or_default(); + + let mut latest_by_channel: HashMap = HashMap::new(); + for metadata in native_events + .iter() + .chain(legacy_events.iter()) + .filter_map(chat_metadata_from_event) + { + let should_replace = latest_by_channel + .get(&metadata.channel_id) + .map(|existing| existing.updated_at < metadata.updated_at) + .unwrap_or(true); + if should_replace { + latest_by_channel.insert(metadata.channel_id.clone(), metadata); + } + } + + Ok(latest_by_channel.into_values().collect()) +} + +pub(super) async fn fetch_chat_metadata_channel_ids( + state: &AppState, +) -> Result, String> { + Ok(fetch_chat_metadata_infos(state) + .await? + .into_iter() + .map(|metadata| metadata.channel_id) + .collect()) +} + +async fn fetch_channel_info(state: &AppState, channel_id: &str) -> Result { + let events = query_relay( + state, + &[serde_json::json!({ + "kinds": [39000], + "#d": [channel_id], + "limit": 1 + })], + ) + .await?; + + events + .first() + .map(|ev| nostr_convert::channel_info_from_event(ev, None, None)) + .transpose()? + .ok_or_else(|| "chat created but metadata not yet available".to_string()) +} + +async fn fetch_chat_channel_info( + state: &AppState, + channel_id: &str, + title: Option<&str>, +) -> Result { + let mut channel = fetch_channel_info(state, channel_id).await?; + channel.channel_type = "chat".to_string(); + if let Some(title) = title.map(str::trim).filter(|value| !value.is_empty()) { + channel.name = title.to_string(); + } + Ok(channel) +} + +#[tauri::command] +pub async fn list_chat_metadata( + state: State<'_, AppState>, +) -> Result, String> { + fetch_chat_metadata_infos(&state).await +} + +#[tauri::command] +pub async fn list_chats(state: State<'_, AppState>) -> Result, String> { + let metadata_infos = fetch_chat_metadata_infos(&state).await.unwrap_or_default(); + let metadata_by_channel: HashMap = metadata_infos + .into_iter() + .map(|metadata| (metadata.channel_id.clone(), metadata)) + .collect(); + let chat_ids: HashSet = metadata_by_channel.keys().cloned().collect(); + let channels = super::channels::get_channels_including_chats(state).await?; + Ok(channels + .into_iter() + .filter_map(|mut channel| { + if channel.channel_type != "chat" && !chat_ids.contains(&channel.id) { + return None; + } + channel.channel_type = "chat".to_string(); + if let Some(title) = metadata_by_channel + .get(&channel.id) + .and_then(|metadata| metadata.title.as_deref()) + .map(str::trim) + .filter(|title| !title.is_empty()) + { + channel.name = title.to_string(); + } + Some(channel) + }) + .collect()) +} + +#[tauri::command] +pub async fn create_chat( + input: CreateChatInput, + state: State<'_, AppState>, +) -> Result { + let channel_uuid = uuid::Uuid::new_v4(); + let title = trimmed(input.title.as_ref()).unwrap_or("New chat"); + + let builder = events::build_create_channel(channel_uuid, title, "private", "chat", None, None)?; + if let Err(error) = submit_event(builder, &state).await { + if !is_unsupported_chat_channel_type_error(&error) { + return Err(error); + } + + eprintln!( + "buzz-desktop: relay does not support channel_type=chat yet; creating private compatibility channel" + ); + let fallback_builder = + events::build_create_channel(channel_uuid, title, "private", "stream", None, None)?; + if let Err(fallback_error) = submit_event(fallback_builder, &state).await { + eprintln!( + "buzz-desktop: failed to create private compatibility chat channel: {fallback_error}" + ); + return Err(format!( + "could not create private compatibility chat channel: {fallback_error}" + )); + } + } + + let source = input.source.as_ref(); + if let Err(metadata_error) = submit_chat_metadata( + &state, + channel_uuid, + Some(title), + trimmed(input.default_agent_pubkey.as_ref()), + trimmed(input.template_id.as_ref()), + trimmed(input.project_id.as_ref()), + trimmed(input.project_name.as_ref()), + trimmed(input.project_path.as_ref()), + trimmed(input.project_template_id.as_ref()), + source_channel_id(source), + source_event_id(source), + source_thread_root_id(source), + ) + .await + { + eprintln!("buzz-desktop: failed to write chat metadata: {metadata_error}"); + return Err(format!( + "chat channel was created, but metadata failed: {metadata_error}" + )); + } + + fetch_chat_channel_info(&state, &channel_uuid.to_string(), Some(title)) + .await + .map_err(|error| format!("chat channel was created, but could not be loaded: {error}")) +} + +#[tauri::command] +pub async fn get_chat_metadata( + channel_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let native_events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [buzz_core_pkg::kind::KIND_CHAT_METADATA], + "#d": [channel_id], + "limit": 1 + })], + ) + .await + .unwrap_or_default(); + if let Some(metadata) = native_events.first().and_then(chat_metadata_from_event) { + return Ok(Some(metadata)); + } + + let legacy_events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [LEGACY_CHAT_METADATA_KIND], + "#d": [legacy_chat_metadata_d_tag(&channel_id)], + "authors": [current_pubkey(&state)?], + "limit": 1 + })], + ) + .await + .unwrap_or_default(); + + Ok(legacy_events.first().and_then(chat_metadata_from_event)) +} + +#[tauri::command] +pub async fn update_chat_metadata( + input: UpdateChatMetadataInput, + state: State<'_, AppState>, +) -> Result { + let uuid = uuid::Uuid::parse_str(&input.channel_id) + .map_err(|_| format!("invalid channel UUID: {}", input.channel_id))?; + let source = input.source.as_ref(); + submit_chat_metadata( + &state, + uuid, + trimmed(input.title.as_ref()), + trimmed(input.default_agent_pubkey.as_ref()), + trimmed(input.template_id.as_ref()), + trimmed(input.project_id.as_ref()), + trimmed(input.project_name.as_ref()), + trimmed(input.project_path.as_ref()), + trimmed(input.project_template_id.as_ref()), + source_channel_id(source), + source_event_id(source), + source_thread_root_id(source), + ) + .await?; + + get_chat_metadata(input.channel_id, state) + .await? + .ok_or_else(|| "chat metadata not available after update".to_string()) +} + +#[tauri::command] +pub async fn send_chat_context_message( + input: SendChatContextMessageInput, + state: State<'_, AppState>, +) -> Result { + let uuid = uuid::Uuid::parse_str(&input.channel_id) + .map_err(|_| format!("invalid channel UUID: {}", input.channel_id))?; + let source = input.source.as_ref(); + let builder = build_chat_context_message( + uuid, + input.content.trim(), + source_channel_id(source), + source_event_id(source), + source_thread_root_id(source), + )?; + let result = submit_event(builder, &state).await?; + + Ok(SendChannelMessageResponse { + event_id: result.event_id, + parent_event_id: None, + root_event_id: None, + depth: 0, + created_at: chrono::Utc::now().timestamp(), + }) +} diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 5cc9c01d33..47e8106740 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -2,13 +2,478 @@ use std::time::Duration; use futures_util::StreamExt; use reqwest::{ - header::{ACCEPT, CONTENT_TYPE, USER_AGENT}, + header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT}, redirect::Policy, }; use url::Url; const MAX_TITLE_FETCH_BYTES: usize = 256 * 1024; const TITLE_FETCH_TIMEOUT: Duration = Duration::from_secs(4); +const GITHUB_API_TIMEOUT: Duration = Duration::from_secs(8); + +/// Shared HTTP client for GitHub API commands: the PR monitor polls on +/// short intervals, and building a fresh client (connection pool + TLS +/// session cache) per call threw away keep-alive reuse on every tick. +fn github_client() -> Result<&'static reqwest::Client, String> { + static CLIENT: std::sync::OnceLock> = std::sync::OnceLock::new(); + CLIENT + .get_or_init(|| { + reqwest::Client::builder() + .pool_idle_timeout(Duration::from_secs(90)) + .pool_max_idle_per_host(2) + .build() + .ok() + }) + .as_ref() + .ok_or_else(|| "github client failed to initialize".to_string()) +} + +/// Live pull-request details for the rich GitHub PR link card. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GithubPullRequestInfo { + pub title: String, + /// GitHub PR state: `open` or `closed` (merged PRs report `closed`). + pub state: String, + pub merged: bool, + pub draft: bool, + pub additions: i64, + pub deletions: i64, + pub changed_files: i64, + /// Source branch of the PR (`head.ref`). + pub head_ref: String, + /// Head commit sha — used to query check runs. + pub head_sha: String, + /// Issue-level comment count. + pub comments: i64, + /// Review (inline) comment count. + pub review_comments: i64, +} + +/// Aggregate check-run state for a commit, for the chat work panel's CI +/// monitor. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GithubCheckSummary { + pub total: i64, + pub pending: i64, + pub failed: i64, + pub succeeded: i64, + /// Individual runs for the expanded view (name + coarse state). + pub runs: Vec, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GithubCheckRun { + pub name: String, + /// `pending` | `success` | `failure`. + pub state: String, +} + +/// Review-thread attention state for a PR: how many threads still await a +/// reply from the PR author. REST cannot see GitHub's "resolved" bit (that +/// is GraphQL-only), so a thread counts as open while its latest comment is +/// from someone other than the PR author — replying clears it, which +/// matches the agent workflow the chat panel automates. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GithubCommentState { + pub open_threads: i64, +} + +/// Fetch live PR details from the GitHub REST API. +/// +/// Anonymous requests cover public repos; when the desktop was launched from +/// a shell with `GITHUB_TOKEN`/`GH_TOKEN` set, the token is attached so +/// private-repo cards work too. Returns `Ok(None)` on any non-success +/// response (not found, rate limited, private without token) — the card +/// falls back to its static form. +#[tauri::command] +pub async fn fetch_github_pull_request( + owner: String, + repo: String, + number: u64, +) -> Result, String> { + if !is_valid_github_name(&owner) || !is_valid_github_name(&repo) { + return Err("invalid GitHub repository reference".to_string()); + } + + let client = github_client()?; + + let url = format!("https://api.github.com/repos/{owner}/{repo}/pulls/{number}"); + let mut request = client + .get(&url) + .timeout(GITHUB_API_TIMEOUT) + .header(ACCEPT, "application/vnd.github+json") + .header(USER_AGENT, "Buzz Desktop link preview") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(token) = ambient_github_token() { + request = request.header(AUTHORIZATION, format!("Bearer {token}")); + } + + let response = request + .send() + .await + .map_err(|error| format!("github request failed: {error}"))?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !response.status().is_success() { + // Rate limits and transient failures must NOT read as "no data" — + // the UI keeps its last good snapshot on error. + return Err(format!("github request failed: {}", response.status())); + } + + let body: serde_json::Value = response + .json() + .await + .map_err(|error| format!("github response parse failed: {error}"))?; + + Ok(Some(GithubPullRequestInfo { + title: body["title"].as_str().unwrap_or_default().to_string(), + state: body["state"].as_str().unwrap_or("open").to_string(), + merged: body["merged"].as_bool().unwrap_or(false), + draft: body["draft"].as_bool().unwrap_or(false), + additions: body["additions"].as_i64().unwrap_or(0), + deletions: body["deletions"].as_i64().unwrap_or(0), + changed_files: body["changed_files"].as_i64().unwrap_or(0), + head_ref: body["head"]["ref"].as_str().unwrap_or_default().to_string(), + head_sha: body["head"]["sha"].as_str().unwrap_or_default().to_string(), + comments: body["comments"].as_i64().unwrap_or(0), + review_comments: body["review_comments"].as_i64().unwrap_or(0), + })) +} + +/// Fetch the check-run summary for a commit. Same auth/fallback behavior as +/// [`fetch_github_pull_request`]: `Ok(None)` on any non-success response. +#[tauri::command] +pub async fn fetch_github_check_summary( + owner: String, + repo: String, + sha: String, +) -> Result, String> { + if !is_valid_github_name(&owner) + || !is_valid_github_name(&repo) + || !sha.chars().all(|c| c.is_ascii_hexdigit()) + || sha.is_empty() + || sha.len() > 64 + { + return Err("invalid GitHub check reference".to_string()); + } + + let client = github_client()?; + + let url = format!( + "https://api.github.com/repos/{owner}/{repo}/commits/{sha}/check-runs?per_page=100" + ); + let mut request = client + .get(&url) + .timeout(GITHUB_API_TIMEOUT) + .header(ACCEPT, "application/vnd.github+json") + .header(USER_AGENT, "Buzz Desktop link preview") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(token) = ambient_github_token() { + request = request.header(AUTHORIZATION, format!("Bearer {token}")); + } + + let response = request + .send() + .await + .map_err(|error| format!("github request failed: {error}"))?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !response.status().is_success() { + // Rate limits and transient failures must NOT read as "no data" — + // the UI keeps its last good snapshot on error. + return Err(format!("github request failed: {}", response.status())); + } + + let body: serde_json::Value = response + .json() + .await + .map_err(|error| format!("github response parse failed: {error}"))?; + + let raw_runs = body["check_runs"].as_array().cloned().unwrap_or_default(); + let mut pending = 0; + let mut failed = 0; + let mut succeeded = 0; + let mut runs = Vec::with_capacity(raw_runs.len()); + for run in &raw_runs { + let state = match run["status"].as_str().unwrap_or_default() { + "completed" => match run["conclusion"].as_str().unwrap_or_default() { + "success" | "neutral" | "skipped" => { + succeeded += 1; + "success" + } + _ => { + failed += 1; + "failure" + } + }, + _ => { + pending += 1; + "pending" + } + }; + runs.push(GithubCheckRun { + name: run["name"].as_str().unwrap_or("check").to_string(), + state: state.to_string(), + }); + } + + Ok(Some(GithubCheckSummary { + total: raw_runs.len() as i64, + pending, + failed, + succeeded, + runs, + })) +} + +/// Count review threads still awaiting the PR author's reply. See +/// [`GithubCommentState`] for semantics and the REST limitation. +#[tauri::command] +pub async fn fetch_github_pr_comment_state( + owner: String, + repo: String, + number: u64, +) -> Result, String> { + if !is_valid_github_name(&owner) || !is_valid_github_name(&repo) { + return Err("invalid GitHub repository reference".to_string()); + } + + let client = github_client()?; + + let base = format!("https://api.github.com/repos/{owner}/{repo}"); + let build = |url: String| { + let mut request = client + .get(url) + .timeout(GITHUB_API_TIMEOUT) + .header(ACCEPT, "application/vnd.github+json") + .header(USER_AGENT, "Buzz Desktop link preview") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(token) = ambient_github_token() { + request = request.header(AUTHORIZATION, format!("Bearer {token}")); + } + request + }; + + let pr_response = build(format!("{base}/pulls/{number}")) + .send() + .await + .map_err(|error| format!("github request failed: {error}"))?; + if pr_response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !pr_response.status().is_success() { + return Err(format!("github request failed: {}", pr_response.status())); + } + let pr: serde_json::Value = pr_response + .json() + .await + .map_err(|error| format!("github response parse failed: {error}"))?; + let author = pr["user"]["login"].as_str().unwrap_or_default().to_string(); + + let comments_response = build(format!( + "{base}/pulls/{number}/comments?per_page=100&sort=created&direction=asc" + )) + .send() + .await + .map_err(|error| format!("github request failed: {error}"))?; + if comments_response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !comments_response.status().is_success() { + return Err(format!( + "github request failed: {}", + comments_response.status() + )); + } + let comments: serde_json::Value = comments_response + .json() + .await + .map_err(|error| format!("github response parse failed: {error}"))?; + + // Group into threads by root comment id; the latest comment (list is + // created-ascending) decides whether the thread still needs the author. + let mut last_author_by_thread: std::collections::HashMap = + std::collections::HashMap::new(); + for comment in comments.as_array().cloned().unwrap_or_default() { + let id = comment["id"].as_i64().unwrap_or_default(); + let root = comment["in_reply_to_id"].as_i64().unwrap_or(id); + let login = comment["user"]["login"] + .as_str() + .unwrap_or_default() + .to_string(); + last_author_by_thread.insert(root, login); + } + let open_threads = last_author_by_thread + .values() + .filter(|login| !author.is_empty() && **login != author) + .count() as i64; + + Ok(Some(GithubCommentState { open_threads })) +} + +/// Discover the pull request for a branch when no PR link has been posted in +/// the chat: read the project's `origin` remote to find the GitHub repo, then +/// look the branch up via the pulls API. Returns the PR's html_url. +#[tauri::command] +pub async fn find_github_pr_for_branch( + project_path: String, + branch: String, +) -> Result, String> { + if branch.is_empty() + || branch.len() > 255 + || !branch + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/')) + { + return Err("invalid branch name".to_string()); + } + + let remote = tokio::task::spawn_blocking(move || { + let output = std::process::Command::new("git") + .arg("-C") + .arg(&project_path) + .args(["remote", "get-url", "origin"]) + .stdin(std::process::Stdio::null()) + .output() + .map_err(|error| format!("git failed to run: {error}"))?; + if !output.status.success() { + return Ok::, String>(None); + } + Ok(Some( + String::from_utf8_lossy(&output.stdout).trim().to_string(), + )) + }) + .await + .map_err(|error| format!("git task failed: {error}"))??; + + let Some(remote) = remote else { + return Ok(None); + }; + let Some((owner, repo)) = parse_github_remote(&remote) else { + return Ok(None); + }; + if !is_valid_github_name(&owner) || !is_valid_github_name(&repo) { + return Ok(None); + } + + let client = github_client()?; + let build = |url: String| { + let mut request = client + .get(url) + .timeout(GITHUB_API_TIMEOUT) + .header(ACCEPT, "application/vnd.github+json") + .header(USER_AGENT, "Buzz Desktop link preview") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(token) = ambient_github_token() { + request = request.header(AUTHORIZATION, format!("Bearer {token}")); + } + request + }; + + // Same-repo branches first (the common case), then a scan of open PRs to + // cover fork-headed pull requests. + let head = format!("{owner}:{branch}"); + let direct = build(format!( + "https://api.github.com/repos/{owner}/{repo}/pulls?head={head}&state=all&per_page=1" + )) + .send() + .await + .map_err(|error| format!("github request failed: {error}"))?; + if direct.status().is_success() { + let pulls: serde_json::Value = direct + .json() + .await + .map_err(|error| format!("github response parse failed: {error}"))?; + if let Some(url) = pulls[0]["html_url"].as_str() { + return Ok(Some(url.to_string())); + } + } + + let open = build(format!( + "https://api.github.com/repos/{owner}/{repo}/pulls?state=open&per_page=100" + )) + .send() + .await + .map_err(|error| format!("github request failed: {error}"))?; + if !open.status().is_success() { + return Ok(None); + } + let pulls: serde_json::Value = open + .json() + .await + .map_err(|error| format!("github response parse failed: {error}"))?; + for pull in pulls.as_array().cloned().unwrap_or_default() { + if pull["head"]["ref"].as_str() == Some(branch.as_str()) { + if let Some(url) = pull["html_url"].as_str() { + return Ok(Some(url.to_string())); + } + } + } + Ok(None) +} + +/// Extract `(owner, repo)` from a GitHub remote URL — ssh +/// (`git@github.com:owner/repo.git`) or https +/// (`https://github.com/owner/repo[.git]`). +fn parse_github_remote(remote: &str) -> Option<(String, String)> { + let rest = remote + .strip_prefix("git@github.com:") + .or_else(|| remote.strip_prefix("ssh://git@github.com/")) + .or_else(|| remote.strip_prefix("https://github.com/")) + .or_else(|| remote.strip_prefix("http://github.com/"))?; + let mut parts = rest.trim_end_matches('/').splitn(2, '/'); + let owner = parts.next()?.to_string(); + let repo = parts.next()?.trim_end_matches(".git").to_string(); + if owner.is_empty() || repo.is_empty() { + return None; + } + Some((owner, repo)) +} + +fn ambient_github_token() -> Option { + static TOKEN: std::sync::OnceLock> = std::sync::OnceLock::new(); + TOKEN + .get_or_init(|| { + let env_token = ["GITHUB_TOKEN", "GH_TOKEN"].iter().find_map(|name| { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }); + if env_token.is_some() { + return env_token; + } + // Desktop apps rarely inherit a token env (launched from Finder or + // a clean shell), but the gh CLI credential is usually present — + // without it the anonymous 60 req/h limit starves the PR monitor + // within minutes. Resolved once per process. + let output = std::process::Command::new("gh") + .args(["auth", "token"]) + .stdin(std::process::Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (!token.is_empty()).then_some(token) + }) + .clone() +} + +fn is_valid_github_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 100 + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) +} #[tauri::command] pub async fn fetch_link_preview_title(href: String) -> Result, String> { diff --git a/desktop/src-tauri/src/commands/message_converters.rs b/desktop/src-tauri/src/commands/message_converters.rs new file mode 100644 index 0000000000..6123d08832 --- /dev/null +++ b/desktop/src-tauri/src/commands/message_converters.rs @@ -0,0 +1,96 @@ +//! Event → UI-model converters shared by the message feed commands. + +use crate::models::{FeedItemInfo, ForumMessageInfo, ForumThreadReplyInfo, ThreadSummary}; + +pub(super) fn channel_id_from_tags(ev: &nostr::Event) -> Option { + ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "h" { + Some(s[1].clone()) + } else { + None + } + }) +} + +pub(super) fn tags_to_vec(ev: &nostr::Event) -> Vec> { + ev.tags.iter().map(|t| t.as_slice().to_vec()).collect() +} + +pub(super) fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo { + let channel_id = channel_id_from_tags(ev); + FeedItemInfo { + id: ev.id.to_hex(), + kind: ev.kind.as_u16() as u32, + pubkey: ev.pubkey.to_hex(), + content: ev.content.clone(), + created_at: ev.created_at.as_secs(), + channel_id, + channel_name: String::new(), + channel_type: None, + tags: tags_to_vec(ev), + category: category.to_string(), + } +} + +pub(super) fn forum_message_from_event(ev: &nostr::Event, channel_id: &str) -> ForumMessageInfo { + ForumMessageInfo { + event_id: ev.id.to_hex(), + pubkey: ev.pubkey.to_hex(), + content: ev.content.clone(), + kind: ev.kind.as_u16() as u32, + created_at: ev.created_at.as_secs() as i64, + channel_id: channel_id.to_string(), + tags: tags_to_vec(ev), + thread_summary: Some(ThreadSummary { + reply_count: 0, + descendant_count: 0, + last_reply_at: None, + participants: Vec::new(), + }), + reactions: serde_json::Value::Null, + } +} + +pub(super) fn forum_reply_from_event( + ev: &nostr::Event, + channel_id: &str, + root_event_id: &str, +) -> ForumThreadReplyInfo { + // Walk e-tags for NIP-10 parent/root markers. + let (mut parent_id, mut explicit_root) = (None, None); + for t in ev.tags.iter() { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "e" { + match s.get(3).map(|x| x.as_str()) { + Some("root") => explicit_root = Some(s[1].clone()), + Some("reply") => parent_id = Some(s[1].clone()), + _ => { + if parent_id.is_none() { + parent_id = Some(s[1].clone()); + } + } + } + } + } + let parent = parent_id + .clone() + .unwrap_or_else(|| root_event_id.to_string()); + let root = explicit_root.unwrap_or_else(|| root_event_id.to_string()); + let depth = if parent == root { 1 } else { 2 }; + + ForumThreadReplyInfo { + event_id: ev.id.to_hex(), + pubkey: ev.pubkey.to_hex(), + content: ev.content.clone(), + kind: ev.kind.as_u16() as u32, + created_at: ev.created_at.as_secs() as i64, + channel_id: channel_id.to_string(), + tags: tags_to_vec(ev), + parent_event_id: Some(parent), + root_event_id: Some(root), + depth, + broadcast: false, + reactions: serde_json::Value::Null, + } +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 3a057eaa18..1765552167 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -1,6 +1,10 @@ use nostr::{Event, EventId, Keys, PublicKey}; use tauri::{AppHandle, State}; +use super::message_converters::{ + feed_item_from_event, forum_message_from_event, forum_reply_from_event, +}; + use crate::{ app_state::AppState, events, @@ -8,7 +12,7 @@ use crate::{ models::{ FeedItemInfo, FeedMeta, FeedResponse, FeedSections, ForumMessageInfo, ForumPostsResponse, ForumThreadReplyInfo, ForumThreadResponse, SearchResponse, SendChannelMessageResponse, - ThreadRepliesResponse, ThreadSummary, + ThreadRepliesResponse, }, nostr_convert, relay::{query_relay, submit_event, submit_event_with_keys}, @@ -484,6 +488,7 @@ pub async fn send_channel_message( mention_tags: Option>>, mention_pubkeys: Option>, kind: Option, + client_tags: Option>>, state: State<'_, AppState>, ) -> Result { let channel_uuid = uuid::Uuid::parse_str(&channel_id) @@ -493,6 +498,7 @@ pub async fn send_channel_message( let media = media_tags.unwrap_or_default(); let emoji = emoji_tags.unwrap_or_default(); let mention_refs_only = mention_tags.unwrap_or_default(); + let client = client_tags.unwrap_or_default(); let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE); let mut resolved_root: Option = None; @@ -529,7 +535,7 @@ pub async fn send_channel_message( } None => None, }; - events::build_message( + events::build_message_with_client_tags( channel_uuid, content.trim(), thread_ref.as_ref(), @@ -537,6 +543,7 @@ pub async fn send_channel_message( &media, &emoji, &mention_refs_only, + &client, )? } }; @@ -986,96 +993,3 @@ pub async fn delete_message( } // ── Local helpers ─────────────────────────────────────────────────────────── - -fn channel_id_from_tags(ev: &nostr::Event) -> Option { - ev.tags.iter().find_map(|t| { - let s = t.as_slice(); - if s.len() >= 2 && s[0] == "h" { - Some(s[1].clone()) - } else { - None - } - }) -} - -fn tags_to_vec(ev: &nostr::Event) -> Vec> { - ev.tags.iter().map(|t| t.as_slice().to_vec()).collect() -} - -fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo { - let channel_id = channel_id_from_tags(ev); - FeedItemInfo { - id: ev.id.to_hex(), - kind: ev.kind.as_u16() as u32, - pubkey: ev.pubkey.to_hex(), - content: ev.content.clone(), - created_at: ev.created_at.as_secs(), - channel_id, - channel_name: String::new(), - channel_type: None, - tags: tags_to_vec(ev), - category: category.to_string(), - } -} - -fn forum_message_from_event(ev: &nostr::Event, channel_id: &str) -> ForumMessageInfo { - ForumMessageInfo { - event_id: ev.id.to_hex(), - pubkey: ev.pubkey.to_hex(), - content: ev.content.clone(), - kind: ev.kind.as_u16() as u32, - created_at: ev.created_at.as_secs() as i64, - channel_id: channel_id.to_string(), - tags: tags_to_vec(ev), - thread_summary: Some(ThreadSummary { - reply_count: 0, - descendant_count: 0, - last_reply_at: None, - participants: Vec::new(), - }), - reactions: serde_json::Value::Null, - } -} - -fn forum_reply_from_event( - ev: &nostr::Event, - channel_id: &str, - root_event_id: &str, -) -> ForumThreadReplyInfo { - // Walk e-tags for NIP-10 parent/root markers. - let (mut parent_id, mut explicit_root) = (None, None); - for t in ev.tags.iter() { - let s = t.as_slice(); - if s.len() >= 2 && s[0] == "e" { - match s.get(3).map(|x| x.as_str()) { - Some("root") => explicit_root = Some(s[1].clone()), - Some("reply") => parent_id = Some(s[1].clone()), - _ => { - if parent_id.is_none() { - parent_id = Some(s[1].clone()); - } - } - } - } - } - let parent = parent_id - .clone() - .unwrap_or_else(|| root_event_id.to_string()); - let root = explicit_root.unwrap_or_else(|| root_event_id.to_string()); - let depth = if parent == root { 1 } else { 2 }; - - ForumThreadReplyInfo { - event_id: ev.id.to_hex(), - pubkey: ev.pubkey.to_hex(), - content: ev.content.clone(), - kind: ev.kind.as_u16() as u32, - created_at: ev.created_at.as_secs() as i64, - channel_id: channel_id.to_string(), - tags: tags_to_vec(ev), - parent_event_id: Some(parent), - root_event_id: Some(root), - depth, - broadcast: false, - reactions: serde_json::Value::Null, - } -} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 68f5e1e786..05fd83959b 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -9,6 +9,7 @@ mod canvas; mod channel_templates; mod channel_window; mod channels; +mod chats; mod dms; mod engrams; mod export_util; @@ -21,6 +22,7 @@ mod media_download; mod media_transcode; #[cfg(feature = "mesh-llm")] mod mesh_llm; +mod message_converters; mod messages; mod notifications; mod observer_archive; @@ -52,6 +54,7 @@ pub use canvas::*; pub use channel_templates::*; pub use channel_window::*; pub use channels::*; +pub use chats::*; pub use dms::*; pub use engrams::*; pub use identity::*; diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 2940cfeec3..a959d0c135 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -33,10 +33,32 @@ fn parse_message_deep_link(url: &Url) -> Option { })) } +fn parse_chat_deep_link(url: &Url) -> Option { + let mut channel: Option = None; + let mut title: Option = None; + for (k, v) in url.query_pairs() { + let v = v.into_owned(); + if v.is_empty() { + continue; + } + match k.as_ref() { + "channel" => channel = Some(v), + "title" => title = Some(v), + _ => {} + } + } + Some(serde_json::json!({ + "chatId": channel?, + "title": title, + })) +} + /// Handle an incoming `buzz://` deep link URL. /// /// Currently supports: /// - `buzz://connect?relay=` — emits `deep-link-connect` to the frontend +/// - `buzz://message?channel=&id=[&thread=]` — emits `deep-link-message` +/// - `buzz://chat?channel=[&title=]` — emits `deep-link-chat` pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { let url = match Url::parse(url_str) { Ok(u) => u, @@ -93,6 +115,13 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { }; let _ = app.emit("deep-link-message", payload); } + Some("chat") => { + let Some(payload) = parse_chat_deep_link(&url) else { + eprintln!("buzz-desktop: chat deep link missing channel: {url_str}"); + return; + }; + let _ = app.emit("deep-link-chat", payload); + } Some(action) => { eprintln!("buzz-desktop: unknown deep link action: {action}"); } @@ -106,7 +135,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { mod tests { use url::Url; - use super::parse_message_deep_link; + use super::{parse_chat_deep_link, parse_message_deep_link}; #[test] fn parse_message_deep_link_extracts_required_params() { @@ -157,4 +186,25 @@ mod tests { let payload = parse_message_deep_link(&url).expect("required params present"); assert!(payload["threadRootId"].is_null()); } + + #[test] + fn parse_chat_deep_link_extracts_channel() { + let url = Url::parse("buzz://chat?channel=chat-1").unwrap(); + let payload = parse_chat_deep_link(&url).expect("required params present"); + assert_eq!(payload["chatId"], "chat-1"); + assert!(payload["title"].is_null()); + } + + #[test] + fn parse_chat_deep_link_includes_title() { + let url = Url::parse("buzz://chat?channel=chat-1&title=Build%20notes").unwrap(); + let payload = parse_chat_deep_link(&url).expect("required params present"); + assert_eq!(payload["title"], "Build notes"); + } + + #[test] + fn parse_chat_deep_link_rejects_empty_channel() { + let url = Url::parse("buzz://chat?channel=").unwrap(); + assert!(parse_chat_deep_link(&url).is_none()); + } } diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 650efffbd3..1d6f676288 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -13,8 +13,6 @@ use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; use nostr::{EventBuilder, EventId, Kind, Tag}; use uuid::Uuid; -// ── Constants ──────────────────────────────────────────────────────────────── - /// Maximum content size — matches buzz-sdk (64 KiB). const MAX_CONTENT_BYTES: usize = 64 * 1024; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 401d910b4c..eb6c233162 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -459,6 +459,10 @@ pub fn run() { get_relay_http_url, get_media_proxy_port, fetch_link_preview_title, + fetch_github_pull_request, + fetch_github_check_summary, + fetch_github_pr_comment_state, + find_github_pr_for_branch, discover_acp_providers, install_acp_runtime, discover_managed_agent_prereqs, @@ -470,6 +474,12 @@ pub fn run() { nip44_decrypt_from_self, get_channels, create_channel, + list_chats, + list_chat_metadata, + create_chat, + get_chat_metadata, + update_chat_metadata, + send_chat_context_message, open_dm, hide_dm, get_channel_details, diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 4d534636af..3d9d848860 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -98,6 +98,8 @@ pub struct ChannelInfo { pub member_count: i64, #[serde(default)] pub member_pubkeys: Vec, + #[serde(default)] + pub created_at: String, pub last_message_at: Option, pub archived_at: Option, #[serde(default)] diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index 64c3b16c02..fb85afc034 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -168,6 +168,7 @@ pub fn channel_info_from_event( purpose, member_count, member_pubkeys: Vec::new(), + created_at: timestamp_to_iso(event.created_at.as_secs()), last_message_at, archived_at, participants, diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index a0fb1c4bd0..1b92d64d1c 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -16,6 +16,14 @@ const DEFAULT_RELAY_WS_URL: &str = "ws://localhost:3000"; // classifier keys on. Extracted to a const so a test can pin that contract. const MALFORMED_RESPONSE_MESSAGE: &str = "relay returned malformed response: not valid JSON"; +// The shared `http_client` deliberately has no global timeout (the media +// proxy streams arbitrarily large bodies through it), so every relay bridge +// request sets its own deadline. Without one, a stalled connection — e.g. an +// expired VPN tunnel that blackholes packets instead of resetting — leaves +// `send()` pending forever, and every UI await upstream (message sends, chat +// creation) spins indefinitely with no error surfaced. +const RELAY_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + fn configured_env_var(name: &str) -> Option { std::env::var(name) .ok() @@ -294,6 +302,7 @@ pub async fn query_relay_at( let response = state .http_client .post(&url) + .timeout(RELAY_REQUEST_TIMEOUT) .header("Authorization", auth) .header("Content-Type", "application/json") .body(body_bytes) @@ -393,6 +402,7 @@ pub async fn sync_managed_agent_profile( let mut request = state .http_client .post(&url) + .timeout(RELAY_REQUEST_TIMEOUT) .header("Authorization", auth) .header("Content-Type", "application/json"); if let Some(tag) = auth_tag { @@ -498,6 +508,7 @@ pub async fn submit_event( let response = state .http_client .post(&url) + .timeout(RELAY_REQUEST_TIMEOUT) .header("Authorization", auth_header) .header("Content-Type", "application/json") .body(body_bytes) @@ -539,6 +550,7 @@ pub async fn submit_signed_event( let response = state .http_client .post(&url) + .timeout(RELAY_REQUEST_TIMEOUT) .header("Authorization", auth_header) .header("Content-Type", "application/json") .body(body_bytes) @@ -580,6 +592,7 @@ pub async fn submit_event_with_keys( let mut request = state .http_client .post(&url) + .timeout(RELAY_REQUEST_TIMEOUT) .header("Authorization", auth_header) .header("Content-Type", "application/json"); if let Some(tag) = auth_tag { diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index 7b89240ab7..36d0ab268b 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -5,6 +5,7 @@ import type { SearchHit } from "@/shared/api/types"; export type AppView = | "home" | "channel" + | "chats" | "agents" | "workflows" | "pulse" @@ -62,6 +63,13 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/chats" || pathname.startsWith("/chats/")) { + return { + selectedChannelId: null, + selectedView: "chats", + }; + } + if (pathname === "/agents") { return { selectedChannelId: null, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e35c41c0d2..a528686ad8 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -116,6 +116,7 @@ export function AppShell() { const queryClient = useQueryClient(); const { goAgents, + goChats, goChannel, goHome, goProjects, @@ -204,10 +205,21 @@ export function AppShell() { () => channels.filter((channel) => channel.isMember), [channels], ); - const sidebarChannels = React.useMemo( + const nonArchivedMemberChannels = React.useMemo( () => memberChannels.filter((channel) => channel.archivedAt === null), [memberChannels], ); + const sidebarChannels = React.useMemo( + () => + nonArchivedMemberChannels.filter( + (channel) => channel.channelType !== "chat", + ), + [nonArchivedMemberChannels], + ); + const searchableChannels = React.useMemo( + () => channels.filter((channel) => channel.channelType !== "chat"), + [channels], + ); const activeChannel = React.useMemo( () => selectedChannelId @@ -261,7 +273,7 @@ export function AppShell() { mutedRootIds, muteThread, unmuteThread, - } = useUnreadChannels(sidebarChannels, activeChannel, { + } = useUnreadChannels(nonArchivedMemberChannels, activeChannel, { pubkey: identityQuery.data?.pubkey, relayClient, currentPubkey: identityQuery.data?.pubkey, @@ -514,7 +526,7 @@ export function AppShell() { unreadChannelNotificationCount, ]); - // Dispatch `buzz://message` deep links into the router. + // Dispatch routed `buzz://` deep links into the router. useMessageDeepLinks(); const handleOpenNewDm = React.useCallback(() => setIsNewDmOpen(true), []); @@ -610,6 +622,8 @@ export function AppShell() { getMessageReadAt, markMessageRead, readStateVersion, + unreadChannelCounts, + unreadChannelIds, setContextParentResolver, followThread: handleFollowThread, unfollowThread: handleUnfollowThread, @@ -795,11 +809,12 @@ export function AppShell() { await goChannel(directMessage.id); }} onSelectAgents={() => void goAgents()} + onSelectChats={() => void goChats()} onSelectChannel={(channelId) => void goChannel(channelId) } onOpenSearchResult={handleOpenSearchResult} - searchChannels={channels} + searchChannels={searchableChannels} searchFocusRequest={searchFocusRequest} onSelectHome={() => void goHome()} onSelectProjects={() => void goProjects()} @@ -862,7 +877,7 @@ export function AppShell() { ; + unreadChannelIds: ReadonlySet; // Inject the thread→channel parent resolver derived from the event graph // (NIP-RS hierarchical frontier). Set by the active channel surface. setContextParentResolver: (resolver: ContextParentResolver | null) => void; @@ -59,6 +61,8 @@ const AppShellContext = React.createContext({ getMessageReadAt: () => null, markMessageRead: () => {}, readStateVersion: 0, + unreadChannelCounts: new Map(), + unreadChannelIds: EMPTY_SET, setContextParentResolver: () => {}, followThread: () => {}, unfollowThread: () => {}, diff --git a/desktop/src/app/navigation/resolveSearchHitDestination.test.mjs b/desktop/src/app/navigation/resolveSearchHitDestination.test.mjs new file mode 100644 index 0000000000..3214da98e4 --- /dev/null +++ b/desktop/src/app/navigation/resolveSearchHitDestination.test.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveSearchHitDestination } from "./resolveSearchHitDestination.ts"; + +test("resolveSearchHitDestination routes chat hits to chats", async () => { + const destination = await resolveSearchHitDestination({ + channelId: "chat-123", + channelType: "chat", + eventId: "event-456", + kind: 9, + }); + + assert.deepEqual(destination, { + kind: "chat", + chatId: "chat-123", + messageId: "event-456", + }); +}); diff --git a/desktop/src/app/navigation/resolveSearchHitDestination.ts b/desktop/src/app/navigation/resolveSearchHitDestination.ts index 2575bcfd48..8de8467e59 100644 --- a/desktop/src/app/navigation/resolveSearchHitDestination.ts +++ b/desktop/src/app/navigation/resolveSearchHitDestination.ts @@ -4,6 +4,11 @@ import type { SearchHit } from "@/shared/api/types"; import { KIND_FORUM_COMMENT, KIND_FORUM_POST } from "@/shared/constants/kinds"; export type SearchHitDestination = + | { + kind: "chat"; + chatId: string; + messageId?: string; + } | { kind: "channel"; channelId: string; @@ -24,6 +29,14 @@ export async function resolveSearchHitDestination( return null; } + if (hit.channelType === "chat") { + return { + kind: "chat", + chatId: hit.channelId, + messageId: hit.eventId, + }; + } + if (hit.kind === KIND_FORUM_POST) { return { kind: "forum-post", diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 820e9f046a..cbea0022e4 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -8,6 +8,7 @@ import { import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination"; +import { NO_PROJECT_SELECTION_ID } from "@/features/chats/lib/chatSetup"; import type { SearchHit } from "@/shared/api/types"; type NavigationBehavior = { @@ -15,6 +16,10 @@ type NavigationBehavior = { resetScroll?: boolean; }; +type ChatsNavigationBehavior = NavigationBehavior & { + projectId?: string | null; +}; + export function useAppNavigation() { const router = useRouter(); const navigate = useNavigate(); @@ -68,6 +73,40 @@ export function useAppNavigation() { [commitNavigation], ); + const goChats = React.useCallback( + (behavior?: ChatsNavigationBehavior) => + commitNavigation( + { + to: "/chats", + search: + behavior && "projectId" in behavior + ? { + projectId: + behavior.projectId === null + ? NO_PROJECT_SELECTION_ID + : behavior.projectId, + } + : {}, + }, + behavior, + ), + [commitNavigation], + ); + + const goChat = React.useCallback( + (chatId: string, behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/chats/$chatId", + params: { + chatId, + }, + }, + behavior, + ), + [commitNavigation], + ); + const goPulse = React.useCallback( (behavior?: NavigationBehavior) => commitNavigation( @@ -261,12 +300,18 @@ export function useAppNavigation() { }); } + if (destination.kind === "chat") { + return goChat(destination.chatId, { + resetScroll: destination.messageId ? true : undefined, + }); + } + return goChannel(destination.channelId, { messageId: destination.messageId, threadRootId: destination.threadRootId, }); }, - [goChannel, goForumPost], + [goChat, goChannel, goForumPost], ); return { @@ -274,6 +319,8 @@ export function useAppNavigation() { closeSettings, closeWorkflowDetail, goAgents, + goChat, + goChats, goChannel, goForumPost, goHome, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index d38c7100af..e3e3c06da1 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -10,10 +10,12 @@ import { Route as settingsRouteImport } from "./routes/settings"; import { Route as remindersRouteImport } from "./routes/reminders"; import { Route as pulseRouteImport } from "./routes/pulse"; import { Route as projectsRouteImport } from "./routes/projects"; +import { Route as chatsRouteImport } from "./routes/chats"; import { Route as agentsRouteImport } from "./routes/agents"; import { Route as indexRouteImport } from "./routes/index"; import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId"; import { Route as projectsDotprojectIdRouteImport } from "./routes/projects.$projectId"; +import { Route as chatsDotchatIdRouteImport } from "./routes/chats.$chatId"; import { Route as channelsDotchannelIdRouteImport } from "./routes/channels.$channelId"; import { Route as channelsDotchannelIdDotpostsDotpostIdRouteImport } from "./routes/channels.$channelId.posts.$postId"; @@ -42,6 +44,11 @@ const projectsRoute = projectsRouteImport.update({ path: "/projects", getParentRoute: () => rootRouteImport, } as any); +const chatsRoute = chatsRouteImport.update({ + id: "/chats", + path: "/chats", + getParentRoute: () => rootRouteImport, +} as any); const agentsRoute = agentsRouteImport.update({ id: "/agents", path: "/agents", @@ -62,6 +69,11 @@ const projectsDotprojectIdRoute = projectsDotprojectIdRouteImport.update({ path: "/projects/$projectId", getParentRoute: () => rootRouteImport, } as any); +const chatsDotchatIdRoute = chatsDotchatIdRouteImport.update({ + id: "/chats/$chatId", + path: "/chats/$chatId", + getParentRoute: () => rootRouteImport, +} as any); const channelsDotchannelIdRoute = channelsDotchannelIdRouteImport.update({ id: "/channels/$channelId", path: "/channels/$channelId", @@ -77,12 +89,14 @@ const channelsDotchannelIdDotpostsDotpostIdRoute = export interface FileRoutesByFullPath { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/chats": typeof chatsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; + "/chats/$chatId": typeof chatsDotchatIdRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; "/workflows/$workflowId": typeof workflowsDotworkflowIdRoute; "/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute; @@ -90,12 +104,14 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/chats": typeof chatsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; + "/chats/$chatId": typeof chatsDotchatIdRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; "/workflows/$workflowId": typeof workflowsDotworkflowIdRoute; "/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute; @@ -104,12 +120,14 @@ export interface FileRoutesById { __root__: typeof rootRouteImport; "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/chats": typeof chatsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; + "/chats/$chatId": typeof chatsDotchatIdRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; "/workflows/$workflowId": typeof workflowsDotworkflowIdRoute; "/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute; @@ -119,12 +137,14 @@ export interface FileRouteTypes { fullPaths: | "/" | "/agents" + | "/chats" | "/projects" | "/pulse" | "/reminders" | "/settings" | "/workflows" | "/channels/$channelId" + | "/chats/$chatId" | "/projects/$projectId" | "/workflows/$workflowId" | "/channels/$channelId/posts/$postId"; @@ -132,12 +152,14 @@ export interface FileRouteTypes { to: | "/" | "/agents" + | "/chats" | "/projects" | "/pulse" | "/reminders" | "/settings" | "/workflows" | "/channels/$channelId" + | "/chats/$chatId" | "/projects/$projectId" | "/workflows/$workflowId" | "/channels/$channelId/posts/$postId"; @@ -145,12 +167,14 @@ export interface FileRouteTypes { | "__root__" | "/" | "/agents" + | "/chats" | "/projects" | "/pulse" | "/reminders" | "/settings" | "/workflows" | "/channels/$channelId" + | "/chats/$chatId" | "/projects/$projectId" | "/workflows/$workflowId" | "/channels/$channelId/posts/$postId"; @@ -159,12 +183,14 @@ export interface FileRouteTypes { export interface RootRouteChildren { indexRoute: typeof indexRoute; agentsRoute: typeof agentsRoute; + chatsRoute: typeof chatsRoute; projectsRoute: typeof projectsRoute; pulseRoute: typeof pulseRoute; remindersRoute: typeof remindersRoute; settingsRoute: typeof settingsRoute; workflowsRoute: typeof workflowsRoute; channelsDotchannelIdRoute: typeof channelsDotchannelIdRoute; + chatsDotchatIdRoute: typeof chatsDotchatIdRoute; projectsDotprojectIdRoute: typeof projectsDotprojectIdRoute; workflowsDotworkflowIdRoute: typeof workflowsDotworkflowIdRoute; channelsDotchannelIdDotpostsDotpostIdRoute: typeof channelsDotchannelIdDotpostsDotpostIdRoute; @@ -207,6 +233,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof projectsRouteImport; parentRoute: typeof rootRouteImport; }; + "/chats": { + id: "/chats"; + path: "/chats"; + fullPath: "/chats"; + preLoaderRoute: typeof chatsRouteImport; + parentRoute: typeof rootRouteImport; + }; "/agents": { id: "/agents"; path: "/agents"; @@ -235,6 +268,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof projectsDotprojectIdRouteImport; parentRoute: typeof rootRouteImport; }; + "/chats/$chatId": { + id: "/chats/$chatId"; + path: "/chats/$chatId"; + fullPath: "/chats/$chatId"; + preLoaderRoute: typeof chatsDotchatIdRouteImport; + parentRoute: typeof rootRouteImport; + }; "/channels/$channelId": { id: "/channels/$channelId"; path: "/channels/$channelId"; @@ -255,12 +295,14 @@ declare module "@tanstack/react-router" { const rootRouteChildren: RootRouteChildren = { indexRoute: indexRoute, agentsRoute: agentsRoute, + chatsRoute: chatsRoute, projectsRoute: projectsRoute, pulseRoute: pulseRoute, remindersRoute: remindersRoute, settingsRoute: settingsRoute, workflowsRoute: workflowsRoute, channelsDotchannelIdRoute: channelsDotchannelIdRoute, + chatsDotchatIdRoute: chatsDotchatIdRoute, projectsDotprojectIdRoute: projectsDotprojectIdRoute, workflowsDotworkflowIdRoute: workflowsDotworkflowIdRoute, channelsDotchannelIdDotpostsDotpostIdRoute: diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index 5dc4789ae6..99061722a1 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -6,6 +6,8 @@ export const routes = rootRoute("root.tsx", [ route("/pulse", "pulse.tsx"), route("/reminders", "reminders.tsx"), route("/settings", "settings.tsx"), + route("/chats", "chats.tsx"), + route("/chats/$chatId", "chats.$chatId.tsx"), route("/workflows", "workflows.tsx"), route("/workflows/$workflowId", "workflows.$workflowId.tsx"), route("/projects", "projects.tsx"), diff --git a/desktop/src/app/routes/chats.$chatId.tsx b/desktop/src/app/routes/chats.$chatId.tsx new file mode 100644 index 0000000000..a491fdbc0b --- /dev/null +++ b/desktop/src/app/routes/chats.$chatId.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +const LazyChatsScreen = React.lazy(async () => { + const module = await import("@/features/chats/ui/ChatsScreen"); + return { default: module.ChatsScreen }; +}); + +export const Route = createFileRoute("/chats/$chatId")({ + component: ChatRoute, +}); + +function ChatRoute() { + const { chatId } = Route.useParams(); + + return ( + + + + ); +} diff --git a/desktop/src/app/routes/chats.tsx b/desktop/src/app/routes/chats.tsx new file mode 100644 index 0000000000..2a662cd860 --- /dev/null +++ b/desktop/src/app/routes/chats.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +import { NO_PROJECT_SELECTION_ID } from "@/features/chats/lib/chatSetup"; + +type ChatsRouteSearch = { + projectId?: string; +}; + +function validateChatsSearch( + search: Record, +): ChatsRouteSearch { + const projectId = search.projectId; + return { + projectId: + typeof projectId === "string" && projectId.trim().length > 0 + ? projectId + : undefined, + }; +} + +const LazyChatsScreen = React.lazy(async () => { + const module = await import("@/features/chats/ui/ChatsScreen"); + return { default: module.ChatsScreen }; +}); + +export const Route = createFileRoute("/chats")({ + validateSearch: validateChatsSearch, + component: ChatsRoute, +}); + +function ChatsRoute() { + const search = Route.useSearch(); + const initialProjectId = + search.projectId === NO_PROJECT_SELECTION_ID ? null : search.projectId; + + return ( + + + + ); +} diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index 75d216e2eb..7212d4a44f 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -1107,6 +1107,52 @@ describe("activeAgentTurnsStore", () => { ); }); + it("establishes a turn from a tool frame when turn_started was never seen", () => { + // Observer frames are ephemeral: an app subscribing mid-turn missed + // turn_started. The first in-turn frame of ANY kind must establish the + // turn so working indicators light up immediately. + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: 1, + kind: "tool_call", + turnId: "t1", + channelId: "c1", + timestamp: at(0), + }), + ]); + assert.ok( + channelIdsOf(getActiveTurnsForAgent(AGENT)).has("c1"), + "a mid-turn tool frame must establish the turn without turn_started", + ); + }); + + it("does NOT establish a turn from a tool frame older than its completion", () => { + // The tombstone gate applies to the establish path too: frames from a + // turn that already terminally ended must not revive it. + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 1, turnId: "t1", channelId: "c1", timestamp: at(0) }), + makeEvent({ + seq: 2, + kind: "turn_completed", + turnId: "t1", + channelId: "c1", + timestamp: at(10_000), + }), + makeEvent({ + seq: 3, + kind: "tool_call", + turnId: "t1", + channelId: "c1", + timestamp: at(8_000), + }), + ]); + assert.equal( + getActiveTurnsForAgent(AGENT).length, + 0, + "a stale tool frame must not revive a completed turn", + ); + }); + it("does NOT resurrect from a liveness frame with no channelId", () => { // A pruned turn cannot be rebuilt without a channelId to anchor the badge. syncAgentTurnsFromEvents(AGENT, [ diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 9ffd34db63..cc63ecc56f 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -42,6 +42,8 @@ type ActiveTurn = { export type ActiveTurnSummary = { channelId: string; anchorAt: number; + /** Ids of the live turns collapsed into this channel summary. */ + turnIds: string[]; }; /** One channel with active agent work, aggregated across agents. */ @@ -51,6 +53,8 @@ export type ActiveChannelTurnSummary = { agentCount: number; agentPubkeys: string[]; agentNames?: string[]; + /** Live turn ids in this channel, across all tracked agents. */ + turnIds: string[]; }; // Module-level state: agentPubkey → turnId → ActiveTurn @@ -350,14 +354,23 @@ function processEvent(agentPubkey: string, event: ObserverEvent) { ); notifyListeners(); return; - case "acp_read": - case "acp_write": + // acp_read / acp_write / turn_liveness fall through here too: + // Any other frame carrying a turn context (tool calls, assistant text, + // …) is equally hard evidence the turn is alive. Observer frames are + // ephemeral — an app that subscribes mid-turn missed `turn_started` + // entirely — so every in-turn frame must be able to establish the turn, + // not just refresh it. Without this, a chat can stream visible activity + // while the working indicators (sidebar shimmer, badges) stay dark + // until the next liveness ping happens to land. + // // turn_liveness keeps a quiet-but-alive turn from being pruned; same - // refresh-only path as stream activity — no surfaced summary change on its - // own, so it only notifies when the offset above actually moved. If the - // 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": { + // refresh-only path as stream activity — no surfaced summary change on + // its own, so it only notifies when the offset above actually moved. If + // the turn was pruned out from under a still-running host (a transient + // drop raced the pause, or the lone-crash residual self-healed), or was + // never seen to start, resurrect it. The terminal tombstone still keeps + // frames from a completed turn (or stale replays) from reviving it. + default: { const refreshed = recordActivity(agentPubkey, event.turnId ?? null); if (!refreshed && resurrectTurn(agentPubkey, event)) { notifyListeners(); @@ -420,17 +433,22 @@ export function getActiveTurnsForAgent( // should count from when the channel's oldest live turn began. Anchors are // derived here (startedAt + offset) so the latest skew estimate applies. const earliestByChannel = new Map(); - for (const turn of agentTurns.values()) { + const turnIdsByChannel = new Map(); + for (const [turnId, turn] of agentTurns.entries()) { const prior = earliestByChannel.get(turn.channelId); if (prior === undefined || turn.startedAt < prior) { earliestByChannel.set(turn.channelId, turn.startedAt); } + const ids = turnIdsByChannel.get(turn.channelId) ?? []; + ids.push(turnId); + turnIdsByChannel.set(turn.channelId, ids); } const result = [...earliestByChannel.entries()] .map(([channelId, startedAt]) => ({ channelId, anchorAt: startedAt + offset, + turnIds: turnIdsByChannel.get(channelId) ?? [], })) .sort((a, b) => a.channelId.localeCompare(b.channelId)); cachedTurnSummaries.set(key, result); @@ -450,25 +468,27 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] { const summaries = new Map< string, - { anchorAt: number; agentPubkeys: Set } + { anchorAt: number; agentPubkeys: Set; turnIds: string[] } >(); for (const [agentKey, agentTurns] of activeTurnsByAgent) { if (agentTurns.size === 0) continue; const offset = clockOffsetByAgent.get(agentKey) ?? 0; - for (const turn of agentTurns.values()) { + for (const [turnId, turn] of agentTurns.entries()) { const anchorAt = turn.startedAt + offset; const summary = summaries.get(turn.channelId); if (!summary) { summaries.set(turn.channelId, { anchorAt, agentPubkeys: new Set([agentKey]), + turnIds: [turnId], }); continue; } summary.agentPubkeys.add(agentKey); + summary.turnIds.push(turnId); if (anchorAt < summary.anchorAt) { summary.anchorAt = anchorAt; } @@ -481,6 +501,7 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] { anchorAt: summary.anchorAt, agentCount: summary.agentPubkeys.size, agentPubkeys: [...summary.agentPubkeys].sort(), + turnIds: summary.turnIds.sort(), })) .sort((a, b) => a.channelId.localeCompare(b.channelId)); cachedChannelTurnSummaries = result; diff --git a/desktop/src/features/agents/agentWorkingSignal.ts b/desktop/src/features/agents/agentWorkingSignal.ts index 1c3a74fc43..f0f526ea8a 100644 --- a/desktop/src/features/agents/agentWorkingSignal.ts +++ b/desktop/src/features/agents/agentWorkingSignal.ts @@ -34,6 +34,8 @@ export type AgentWorkingChannel = { channelId: string; /** Desktop-clock anchor for elapsed displays (turn start / first typing). */ anchorAt: number; + /** Live observer turn ids for this channel; typing fallback has none. */ + turnIds: string[]; source: Exclude; }; @@ -86,7 +88,6 @@ function notify() { export function subscribeAgentWorkingSignal(listener: () => void) { listeners.add(listener); if (listeners.size === 1) { - invalidateCaches(); unsubscribeTurns = subscribeActiveAgentTurns(notify); } return () => { @@ -94,6 +95,7 @@ export function subscribeAgentWorkingSignal(listener: () => void) { if (listeners.size === 0) { unsubscribeTurns?.(); unsubscribeTurns = null; + invalidateCaches(); } }; } @@ -140,6 +142,7 @@ function computeAgentWorkingState( const channels: AgentWorkingChannel[] = turns.map((turn) => ({ channelId: turn.channelId, anchorAt: turn.anchorAt, + turnIds: turn.turnIds, source: "observer" as const, })); const observerChannelIds = new Set(turns.map((turn) => turn.channelId)); @@ -153,6 +156,7 @@ function computeAgentWorkingState( channels.push({ channelId: typingChannelId, anchorAt: since, + turnIds: [], source: "typing", }); } @@ -191,17 +195,12 @@ export function getAgentWorkingState( return IDLE_STATE; } const cacheKey = `${normalizePubkey(agentPubkey)}|${channelId ?? ""}`; - const useCache = listeners.size > 0; - if (useCache) { - const cached = stateCache.get(cacheKey); - if (cached) { - return cached; - } + const cached = stateCache.get(cacheKey); + if (cached) { + return cached; } const state = computeAgentWorkingState(agentPubkey, channelId); - if (useCache) { - stateCache.set(cacheKey, state); - } + stateCache.set(cacheKey, state); return state; } @@ -212,8 +211,7 @@ export function getAgentWorkingState( * first-seen typing. */ export function getWorkingChannels(): WorkingChannelSummary[] { - const useCache = listeners.size > 0; - if (useCache && channelsCache) { + if (channelsCache) { return channelsCache; } @@ -255,6 +253,7 @@ export function getWorkingChannels(): WorkingChannelSummary[] { anchorAt, agentCount: entries.size, agentPubkeys: [...entries.keys()], + turnIds: [], source: "typing", }); } @@ -262,9 +261,7 @@ export function getWorkingChannels(): WorkingChannelSummary[] { const result = [...byChannel.values()].sort((a, b) => a.channelId.localeCompare(b.channelId), ); - if (useCache) { - channelsCache = result; - } + channelsCache = result; return result; } @@ -280,12 +277,9 @@ export function getWorkingAgentPubkeysForChannel( if (!channelId) { return EMPTY_PUBKEYS; } - const useCache = listeners.size > 0; - if (useCache) { - const cached = channelPubkeysCache.get(channelId); - if (cached) { - return cached; - } + const cached = channelPubkeysCache.get(channelId); + if (cached) { + return cached; } const merged = new Set(); for (const summary of getActiveTurnsByChannel()) { @@ -303,9 +297,7 @@ export function getWorkingAgentPubkeysForChannel( } } const result = merged.size === 0 ? EMPTY_PUBKEYS : [...merged].sort(); - if (useCache) { - channelPubkeysCache.set(channelId, result); - } + channelPubkeysCache.set(channelId, result); return result; } diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index adf6ea92d9..765a021403 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -41,6 +41,11 @@ const eventsByAgent = new Map(); const transcriptByAgent = new Map(); const snapshotByAgent = new Map(); +// Agent-generated conversation titles from `chat_title` frames, keyed by +// channel id. Consumed by the chats auto-title flow; never rendered as a +// transcript row. +const chatTitleByChannel = new Map(); + // Per-agent listeners for `control_result` frames. The ModelPicker subscribes // here to learn the async outcome of a `switch_model` frame (the send is // fire-and-forget; the harness replies out-of-band over the observer relay). @@ -211,6 +216,12 @@ async function handleRelayObserverEvent( if (activeGeneration !== generation) { return; } + if (parsed.kind === "chat_title") { + // Consumed by the chats auto-title flow — deliberately kept out of the + // transcript event stream so it never renders as an activity row. + recordChatTitle(parsed); + return; + } appendAgentEvent(agentPubkey, parsed); if (parsed.kind === "session_config_captured") { void putAgentSessionConfig(agentPubkey, parsed.payload); @@ -502,6 +513,30 @@ export function syncAgentObserverEvents( } } +function recordChatTitle(event: ObserverEvent) { + const channelId = event.channelId; + const title = + typeof (event.payload as { title?: unknown })?.title === "string" + ? ((event.payload as { title: string }).title ?? "").trim() + : ""; + if (!channelId || title.length === 0) { + return; + } + if (chatTitleByChannel.get(channelId) === title) { + return; + } + chatTitleByChannel.set(channelId, title); + notifyListeners(); +} + +/** Latest agent-generated conversation title for a channel, if any. */ +export function getAgentChatTitle(channelId: string | null | undefined) { + if (!channelId) { + return null; + } + return chatTitleByChannel.get(channelId) ?? null; +} + export function resetAgentObserverStore() { generation += 1; const unsubscribe = unsubscribeRelay; @@ -513,6 +548,7 @@ export function resetAgentObserverStore() { snapshotByAgent.clear(); knownAgentPubkeys.clear(); knownAgentsBySubscription.clear(); + chatTitleByChannel.clear(); onSessionConfigCaptured = null; connectionState = "idle"; errorMessage = null; diff --git a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx index 05441e8618..52864da796 100644 --- a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx @@ -43,7 +43,10 @@ export function AddAgentToChannelDialog({ const channels = React.useMemo( () => (channelsQuery.data ?? []).filter( - (channel) => channel.channelType !== "dm" && !channel.archivedAt, + (channel) => + channel.channelType !== "dm" && + channel.channelType !== "chat" && + !channel.archivedAt, ), [channelsQuery.data], ); diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index c6f9f5eb7d..d1d5417133 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -60,7 +60,10 @@ export function AddTeamToChannelDialog({ const channels = React.useMemo( () => (channelsQuery.data ?? []).filter( - (channel) => channel.channelType !== "dm" && !channel.archivedAt, + (channel) => + channel.channelType !== "dm" && + channel.channelType !== "chat" && + !channel.archivedAt, ), [channelsQuery.data], ); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs index 9c0bd30fc8..f89e5f0538 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -320,8 +320,58 @@ test("buildTranscriptDisplayBlocks keeps non-contiguous same-kind runs expanded" ]); assert.equal(block.kind, "turn"); + // read-1 must not merge across the interleaved shell command; only the + // contiguous trailing pair summarizes. assert.deepEqual( block.segments.map((segment) => segment.kind), - ["item", "item", "item", "item"], + ["item", "item", "summary"], ); + assert.deepEqual( + block.segments[2].summary.items.map((item) => item.id), + ["read-2", "read-3"], + ); +}); + +test("buildTranscriptDisplayBlocks keeps an executing tool out of the summary run", () => { + const mkShell = (id, status) => ({ + id, + type: "tool", + renderClass: "shell", + descriptor: { + renderClass: "shell", + label: "Ran command", + preview: id, + source: "harness", + groupKey: "shell:command", + }, + title: "Ran command", + toolName: "shell", + buzzToolName: null, + status, + args: {}, + result: "", + isError: false, + timestamp: "2026-06-18T00:00:00Z", + startedAt: "2026-06-18T00:00:00Z", + completedAt: status === "completed" ? "2026-06-18T00:00:01Z" : null, + turnId: "turn-1", + sessionId: "sess-1", + channelId: "chan-1", + }); + + const [block] = buildTranscriptDisplayBlocks([ + mkShell("shell-1", "completed"), + mkShell("shell-2", "completed"), + mkShell("shell-3", "executing"), + ]); + + assert.equal(block.kind, "turn"); + // Completed commands fold into "Ran 2 commands"; the in-flight command + // stays visible as its own live row until it completes. + assert.deepEqual( + block.segments.map((segment) => segment.kind), + ["summary", "item"], + ); + assert.equal(block.segments[0].summary.label, "Ran 2 commands"); + assert.equal(block.segments[1].item.id, "shell-3"); }); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index e9d0fb0ce1..ab4c4eb4e7 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -128,7 +128,7 @@ function groupSameKindSegments( run.push(next.item); j += 1; } - if (run.length >= minimumSummaryRunLength(run[0])) { + if (run.length >= MINIMUM_SUMMARY_RUN_LENGTH) { grouped.push({ kind: "summary", summary: { @@ -151,6 +151,10 @@ function groupSameKindSegments( function sameKindKey(item: TranscriptItem): string | null { if (item.type !== "tool") return null; + // In-flight tools never join a summary run: the live row (running label, + // elapsed time, shimmer) stays visible on its own and only folds into the + // summary count once it completes. + if (item.status === "executing" || item.status === "pending") return null; const renderClass = getRenderClass(item); if (renderClass === "message") { return null; @@ -176,9 +180,9 @@ function sameKindLabel(item: TranscriptItem, count: number): string { return `${label} ×${count}`; } -function minimumSummaryRunLength(item: TranscriptItem): number { - return getRenderClass(item) === "file-edit" ? 2 : 3; -} +// Two adjacent same-kind rows already read as repetition ("Ran command", +// "Ran command"), so fold them the moment a second completes. +const MINIMUM_SUMMARY_RUN_LENGTH = 2; function getRenderClass(item: TranscriptItem) { if (item.type !== "tool") return item.renderClass; diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index 5daf00d2b4..9ae24c3b29 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -33,10 +33,13 @@ export function parsePromptText(text: string): { }; } - const eventSection = sections.find((section) => { - const title = section.title.toLowerCase(); - return title.startsWith("buzz event"); - }); + // Merged prompts (cancel-merge, backlog batches) carry one section per + // triggering event; the LAST one is the newest message and is what the + // prompt item should represent (its id also drives chat activity + // placement, which anchors to the user's latest message). + const eventSection = [...sections] + .reverse() + .find((section) => section.title.toLowerCase().startsWith("buzz event")); const eventContent = eventSection ? extractEventContent(eventSection.body) : ""; diff --git a/desktop/src/features/agents/ui/useObserverEvents.ts b/desktop/src/features/agents/ui/useObserverEvents.ts index 73603a029d..7a59db48c3 100644 --- a/desktop/src/features/agents/ui/useObserverEvents.ts +++ b/desktop/src/features/agents/ui/useObserverEvents.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { ensureRelayObserverSubscription, + getAgentChatTitle, getAgentObserverSnapshot, getAgentTranscript, ingestArchivedObserverEvents, @@ -156,3 +157,75 @@ export function useLoadArchivedObserverEvents(enabled: boolean) { return { fetchOlderArchived, hasOlderArchived }; } + +const EMPTY_MERGED_TRANSCRIPT: TranscriptItem[] = []; + +/** + * Transcript items merged across several agents, ordered by timestamp — for + * surfaces (chats) where more than one agent can be working and every + * agent's activity must render. The merged array reference is stable until + * one of the underlying per-agent transcripts changes. + */ +export function useAgentsTranscript( + enabled: boolean, + agentPubkeys: readonly string[], +): TranscriptItem[] { + const cacheRef = React.useRef<{ + parts: TranscriptItem[][]; + merged: TranscriptItem[]; + } | null>(null); + + const getSnapshot = React.useCallback(() => { + if (!enabled || agentPubkeys.length === 0) { + return EMPTY_MERGED_TRANSCRIPT; + } + const parts = agentPubkeys.map((pubkey) => + getAgentTranscript(pubkey, true), + ); + const cached = cacheRef.current; + if ( + cached && + cached.parts.length === parts.length && + parts.every((part, index) => part === cached.parts[index]) + ) { + return cached.merged; + } + const merged = + parts.length === 1 + ? parts[0] + : parts + .flat() + .sort( + (left, right) => + Date.parse(left.timestamp) - Date.parse(right.timestamp), + ); + cacheRef.current = { parts, merged }; + return merged; + }, [agentPubkeys, enabled]); + + const snapshot = React.useSyncExternalStore(subscribeToStore, getSnapshot); + + React.useEffect(() => { + if (enabled && agentPubkeys.length > 0) { + void ensureRelayObserverSubscription(); + } + }, [enabled, agentPubkeys]); + + return snapshot; +} + +/** + * Latest agent-generated conversation title (`chat_title` observer frame) + * for a channel. Requires an active observer subscription — pair with + * `useAgentTranscript`/`useObserverEvents`, which establish it. + */ +export function useAgentChatTitle( + channelId: string | null | undefined, +): string | null { + const getSnapshot = React.useCallback( + () => getAgentChatTitle(channelId), + [channelId], + ); + + return React.useSyncExternalStore(subscribeToStore, getSnapshot); +} diff --git a/desktop/src/features/channel-templates/useApplyTemplate.ts b/desktop/src/features/channel-templates/useApplyTemplate.ts index 1f7d066fad..cf405ec69d 100644 --- a/desktop/src/features/channel-templates/useApplyTemplate.ts +++ b/desktop/src/features/channel-templates/useApplyTemplate.ts @@ -13,6 +13,7 @@ import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRunti import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas"; import { useLastRuntime } from "@/features/agents/lib/useLastRuntime"; import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks"; +import { buildChatCanvasContent } from "@/features/chats/lib/chatSetup"; import { setCanvas } from "@/shared/api/tauri"; import type { ChannelTemplate } from "@/shared/api/types"; @@ -38,15 +39,19 @@ export function useApplyTemplate() { templateId: string | undefined, channelId: string, channelName: string, + leadingContent?: string | null, ) { - if (!templateId) return; - const template = channelTemplatesQuery.data?.find( - (t) => t.id === templateId, - ); - if (!template?.canvasTemplate) return; - const content = template.canvasTemplate - .replace(/\{channel\.name\}/g, channelName) - .replace(/\{template\.name\}/g, template.name); + let template: ChannelTemplate | null = null; + if (templateId) { + template = + channelTemplatesQuery.data?.find((t) => t.id === templateId) ?? null; + } + const content = buildChatCanvasContent({ + channelName, + leadingContent, + template, + }); + if (!content) return; try { await setCanvas({ channelId, content }); } catch { diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 2c41ff1e74..bf6a00df5b 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -46,6 +46,7 @@ const channelTypeOrder = { stream: 0, forum: 1, dm: 2, + chat: 3, } as const; function sortChannels(channels: Channel[]) { diff --git a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx index 8022f02cf9..607feffc27 100644 --- a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx +++ b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx @@ -91,6 +91,7 @@ export function ChannelBrowserDialog({ const filtered = channels.filter( (channel) => channel.channelType !== "dm" && + channel.channelType !== "chat" && (channel.archivedAt ? channel.isMember : channel.visibility === "open" || channel.isMember) && diff --git a/desktop/src/features/channels/ui/ChannelCanvas.tsx b/desktop/src/features/channels/ui/ChannelCanvas.tsx index 785be39227..093cb6e562 100644 --- a/desktop/src/features/channels/ui/ChannelCanvas.tsx +++ b/desktop/src/features/channels/ui/ChannelCanvas.tsx @@ -17,19 +17,24 @@ import { type ChannelCanvasProps = { channelId: string | null; canEdit: boolean; + emptyMessage?: string; isArchived: boolean; }; export function ChannelCanvas({ channelId, canEdit, + emptyMessage = "No canvas set for this channel.", isArchived, }: ChannelCanvasProps) { const canvasQuery = useCanvasQuery(channelId, channelId !== null); const setCanvasMutation = useSetCanvasMutation(channelId); const { channels } = useChannelNavigation(); const channelNames = React.useMemo( - () => channels.filter((c) => c.channelType !== "dm").map((c) => c.name), + () => + channels + .filter((c) => c.channelType !== "dm" && c.channelType !== "chat") + .map((c) => c.name), [channels], ); const [isEditing, setIsEditing] = React.useState(false); @@ -130,9 +135,7 @@ export function ChannelCanvas({ /> ) : ( -

- No canvas set for this channel. -

+

{emptyMessage}

)} {canEdit && !isArchived ? ( +
+ {isOpen ? ( +
+
+ {detailItems.map((item) => ( + + ))} +
+
+ ) : null} + + + ); +} + +// How many of the turn's most recent raw observer events the "Working" +// dropdown shows. Enough to read the current backend activity without +// rendering the whole turn's wire history. +const LIVE_TURN_RAW_EVENT_LIMIT = 12; + +function LiveTurnMarker({ + agentPubkey, + icon, + startedAt, + turnId, +}: { + agentPubkey?: string | null; + icon: React.ReactNode; + startedAt: string | null; + turnId: string; +}) { + const elapsedSeconds = useElapsedSeconds(startedAt); + const { events } = useObserverEvents(Boolean(agentPubkey), agentPubkey); + const turnEvents = React.useMemo( + () => + events + .filter((event) => event.turnId === turnId) + .slice(-LIVE_TURN_RAW_EVENT_LIMIT), + [events, turnId], + ); + + return ( + 0 ? ( +
+ +
+ ) : ( + Waiting for the first backend event… + ) + } + // No entrance animation: this row mounts and unmounts with every gap + // between tool executions, and replaying the fade on each reappearance + // reads as blinking instead of a steady status. + icon={icon} + label="Working" + loading + meta={formatElapsedCounter(elapsedSeconds)} + timestamp={startedAt ?? undefined} + tone="muted" + /> + ); +} + +function CompletedWorkDetailRow({ item }: { item: TranscriptItem }) { + const details = activityItemDetails(item); + return ( + + ); +} + +function ToolMarker({ + item, +}: { + item: Extract; +}) { + const compactSummary = buildCompactToolSummary(item); + const hasArgs = Object.keys(item.args).length > 0; + const hasResult = item.result.trim().length > 0; + const canonicalToolName = item.buzzToolName ?? item.toolName; + const buzzTool = getBuzzToolInfo(canonicalToolName); + const shellCommand = getShellCommand(item, compactSummary); + const showDetails = + hasArgs || + hasResult || + compactSummary.fileEditDiff !== null || + compactSummary.thumbnailSrc !== null || + shellCommand !== null; + + return ( + + ) : null + } + entrance={hasRecentEntrance(item.timestamp)} + icon={toolIcon(item, compactSummary)} + label={toolLabel(item)} + loading={isToolRunning(item)} + meta={getToolDurationDisplay(item)} + timestamp={item.timestamp} + tone={ + item.isError || item.status === "failed" + ? "danger" + : item.status === "executing" || item.status === "pending" + ? "warning" + : "default" + } + /> + ); +} + +function ActivityPreviewLine({ item }: { item: TranscriptItem }) { + if (item.type === "tool") { + const summary = buildCompactToolSummary(item); + return ( +
+ + {toolLabel(item)} + + {summary.preview && summary.kind !== "shell" ? ( + + {summary.preview} + + ) : null} +
+ ); + } + + return ( +
+ {activityItemLabel(item)} +
+ ); +} + +function activityItemDetails(item: TranscriptItem) { + if (item.type === "tool") { + const compactSummary = buildCompactToolSummary(item); + const hasArgs = Object.keys(item.args).length > 0; + const hasResult = item.result.trim().length > 0; + const canonicalToolName = item.buzzToolName ?? item.toolName; + const buzzTool = getBuzzToolInfo(canonicalToolName); + const shellCommand = getShellCommand(item, compactSummary); + return ( + + ); + } + if (item.type === "metadata") { + return ; + } + if (item.type === "plan" || item.type === "thought") { + return ; + } + if (item.type === "lifecycle") { + return item.text ? : null; + } + return null; +} + +function PromptSections({ sections }: { sections: PromptSection[] }) { + if (sections.length === 0) { + return ( +

No context captured.

+ ); + } + + return ( +
+ {sections.map((section) => ( +
+ + {section.title} + + +
+ +
+
+ ))} +
+ ); +} + +function isCompletedTurn( + block: Extract, +) { + if (!collectAssistantMessages(block).some((item) => item.text.trim())) { + return false; + } + + return !collectTurnItems(block).some( + (item) => + item.type === "tool" && + (item.status === "executing" || item.status === "pending"), + ); +} + +function collectAssistantMessages( + block: Extract, +) { + return collectTurnItems(block).filter( + (item): item is Extract => + item.type === "message" && item.role === "assistant", + ); +} + +function collectFinalAssistantMessages( + block: Extract, +) { + const messages = collectAssistantMessages(block).filter( + isHumanFacingAssistantMessage, + ); + const finalMessage = messages[messages.length - 1]; + return finalMessage ? [finalMessage] : []; +} + +function collectCompletedActivityItems( + block: Extract, +) { + const items: TranscriptItem[] = []; + for (const segment of block.segments) { + if (segment.kind === "prompt") { + items.push(...segment.setup); + if (segment.context) { + items.push(segment.context); + } + } else if (segment.kind === "setup") { + items.push(...segment.items); + } else if (segment.kind === "summary") { + items.push(...segment.summary.items); + } else if ( + segment.item.type !== "message" || + segment.item.role !== "assistant" + ) { + items.push(segment.item); + } + } + return items.filter((item) => activityItemLabel(item).length > 0); +} + +function collectTurnItems( + block: Extract, +) { + const items: TranscriptItem[] = []; + for (const segment of block.segments) { + if (segment.kind === "prompt") { + items.push(segment.user, ...segment.setup); + if (segment.context) { + items.push(segment.context); + } + } else if (segment.kind === "setup") { + items.push(...segment.items); + } else if (segment.kind === "summary") { + items.push(...segment.summary.items); + } else { + items.push(segment.item); + } + } + return items; +} + +function isActivityItemLive(item: TranscriptItem) { + return item.type === "tool" && isToolRunning(item); +} + +function hasLiveActivityItem( + block: Extract, +) { + return collectTurnItems(block).some(isActivityItemLive); +} + +function liveTurnMarkerIcon( + block: Extract, +) { + const latestTool = [...collectTurnItems(block)] + .reverse() + .find( + (item): item is Extract => + item.type === "tool", + ); + if (!latestTool) { + return ; + } + return toolCategoryIcon(latestTool, buildCompactToolSummary(latestTool)); +} + +function getTurnStartedAt( + block: Extract, +) { + const items = collectTurnItems(block); + const turnStarted = items.find( + (item): item is Extract => + item.type === "lifecycle" && + ((item.acpSource ?? "") === "turn_started" || + item.title.toLowerCase().includes("turn started")), + ); + if (turnStarted) { + return turnStarted.timestamp; + } + + const timestamps = items + .map((item) => Date.parse(item.timestamp)) + .filter((value) => Number.isFinite(value)); + if (timestamps.length === 0) { + return null; + } + return new Date(Math.min(...timestamps)).toISOString(); +} + +function useElapsedSeconds(startedAt: string | null) { + const [now, setNow] = React.useState(() => Date.now()); + + React.useEffect(() => { + const interval = window.setInterval(() => { + setNow(Date.now()); + }, 1000); + return () => window.clearInterval(interval); + }, []); + + if (!startedAt) { + return null; + } + + const start = Date.parse(startedAt); + if (!Number.isFinite(start)) { + return null; + } + return Math.max(0, Math.floor((now - start) / 1000)); +} + +function formatElapsedCounter(seconds: number | null) { + if (seconds === null) { + return null; + } + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`; +} + +function isToolRunning(item: Extract) { + return item.status === "executing" || item.status === "pending"; +} + +function activityItemIcon(item: TranscriptItem) { + const toolIconNode = toolActivityItemIcon(item); + if (toolIconNode) { + return toolIconNode; + } + if (item.type === "metadata") { + return ; + } + if (item.type === "plan") { + return ; + } + if (item.type === "thought") { + return ; + } + if (item.type === "lifecycle") { + return lifecycleIcon(item); + } + return ; +} + +function lifecycleIcon(item: Extract) { + const source = item.acpSource ?? ""; + const title = item.title.toLowerCase(); + if (source === "turn_started" || title.includes("turn started")) { + return ; + } + if (source === "session_resolved" || title.includes("session ready")) { + return ; + } + const renderClass = item.renderClass; + if (renderClass === "permission") { + return ; + } + if (renderClass === "error") { + return ; + } + if (renderClass === "status") { + return ; + } + return ; +} diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx new file mode 100644 index 0000000000..3f5066c1ee --- /dev/null +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -0,0 +1,331 @@ +import * as React from "react"; +import { + Bot, + FolderGit2, + GitPullRequest, + MessageCircle, + Power, + Wand2, +} from "lucide-react"; + +import { chatAutomationLabel } from "@/features/chats/lib/chatWorkAutomation"; +import { cleanAssistantMessageText } from "@/features/chats/ui/chatActivityText"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { RelayEvent } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { + resolveMentionNames, + resolveMentionPubkeysByName, +} from "@/shared/lib/resolveMentionNames"; +import { Bubble } from "@/shared/ui/bubble"; +import { Button } from "@/shared/ui/button"; +import { Markdown } from "@/shared/ui/markdown"; +import { Marker, MarkerContent, MarkerIcon } from "@/shared/ui/marker"; +import { + Message, + MessageAvatar, + MessageContent, + MessageHeader, +} from "@/shared/ui/message"; +import { useMessageScroller } from "@/shared/ui/message-scroller"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +function profileName( + pubkey: string, + profiles: UserProfileLookup | undefined, + fallback = "Unknown", +) { + const profile = profiles?.[pubkey.toLowerCase()]; + return ( + profile?.displayName?.trim() || + profile?.nip05Handle?.trim() || + `${fallback} ${pubkey.slice(0, 8)}` + ); +} + +// Memoized: ChatDetail re-renders on every observer frame during a live +// turn, and without this every persisted message re-renders its whole +// Markdown tree each time. All props are identity-stable between events. +export const ChatMessageRow = React.memo(function ChatMessageRow({ + event, + isAgent, + isOwn, + profiles, + showAgentIdentity = true, +}: { + event: RelayEvent; + isAgent: boolean; + isOwn: boolean; + profiles?: UserProfileLookup; + /** + * Solo chats (one human, one agent) hide the agent's avatar/name so its + * replies read as part of the stream. + */ + showAgentIdentity?: boolean; +}) { + const hideIdentity = isAgent && !showAgentIdentity; + const displayName = profileName( + event.pubkey, + profiles, + isOwn ? "You" : isAgent ? "Fizz" : "User", + ); + const profile = profiles?.[event.pubkey.toLowerCase()]; + const content = isAgent + ? cleanAssistantMessageText(event.content) + : event.content; + // @mentions render as chips, resolved from the message's p/mention tags — + // same treatment as channel messages, split into people vs agent chips. + const mentionNames = React.useMemo( + () => resolveMentionNames(event.tags, profiles), + [event.tags, profiles], + ); + const mentionPubkeysByName = React.useMemo( + () => resolveMentionPubkeysByName(event.tags, profiles), + [event.tags, profiles], + ); + const agentMentionPubkeysByName = React.useMemo(() => { + if (!mentionPubkeysByName) { + return undefined; + } + const values: Record = {}; + for (const [name, pubkey] of Object.entries(mentionPubkeysByName)) { + if (profiles?.[pubkey.toLowerCase()]?.isAgent) { + values[name] = pubkey; + } + } + return Object.keys(values).length > 0 ? values : undefined; + }, [mentionPubkeysByName, profiles]); + + return ( + + {!isOwn && !hideIdentity ? ( + + + + ) : null} + + {/* Own bubbles need no "You" header — the right-aligned bubble is + self-explanatory. Other authors keep their name (unless the solo + chat hides the lone agent's identity). */} + {!hideIdentity && !isOwn ? ( + + {displayName} + + ) : null} + {isAgent ? ( + + ) : ( + + + + )} + + + ); +}); + +// Following the stream is owned entirely by the MessageScroller's built-in +// autoScroll (content mutation + resize observers). This anchor only handles +// the one case autoScroll intentionally skips: jumping back to the bottom +// when the user sends a message while scrolled up in history. +export function ChatScrollAnchor({ + forceSignature, +}: { + forceSignature: string | null; +}) { + const { scrollToEnd } = useMessageScroller(); + const lastForcedSignatureRef = React.useRef(null); + + React.useLayoutEffect(() => { + if ( + forceSignature === null || + forceSignature === lastForcedSignatureRef.current + ) { + return; + } + lastForcedSignatureRef.current = forceSignature; + scrollToEnd({ behavior: "auto" }); + }, [forceSignature, scrollToEnd]); + + return null; +} + +/** + * Marker row for an invisible automation prompt (auto-fix CI / address + * comments): the instruction itself stays out of the timeline, but the spot + * where it fired needs an anchor — total invisibility made "Run now" feel + * like it did nothing. Expandable to the full instruction text. + */ +export function ChatAutomationRow({ + agentName, + event, +}: { + agentName: string; + event: RelayEvent; +}) { + const tag = event.tags.find( + (candidate) => candidate[0] === "client" && candidate[1] === "automation", + ); + return ( + + +
+ + + + {chatAutomationLabel(tag, agentName)} + + +
+ {event.content} +
+
+
+
+ ); +} + +export function ChatContextRow({ event }: { event: RelayEvent }) { + const isProjectSetup = event.content.startsWith("Project setup"); + const isWorkContext = event.content.startsWith("Work context"); + const projectSetupContent = event.content + .replace(/^Project setup\s*\n?/, "") + .trim(); + const workContextContent = event.content + .replace(/^Work context\s*\n?/, "") + .trim(); + + if (isProjectSetup) { + return ( + + +
+ + + + + Project setup + +
+ +
+
+
+
+ ); + } + + if (isWorkContext) { + return ( + + +
+ + + + + Work context + +
+ +
+
+
+
+ ); + } + + return ( + + + + + + + Source context + + + + + + + ); +} + +export function AgentActivationCard({ + agentName, + isActivating, + onActivate, +}: { + agentName: string; + isActivating: boolean; + onActivate: () => void; +}) { + return ( + + +
+
+
+
+ +
+
+

+ {isActivating + ? `Starting ${agentName}…` + : `Activate ${agentName} to get a response`} +

+

+ {isActivating + ? "It will pick up your message as soon as it connects." + : "Your message was sent, but this agent is not active in this chat yet."} +

+
+
+ +
+
+
+
+ ); +} diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx new file mode 100644 index 0000000000..c9c2f770b6 --- /dev/null +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -0,0 +1,998 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { MessageCircle } from "lucide-react"; +import { toast } from "sonner"; + +import { useActiveAgentTurnsByChannel } from "@/features/agents/activeAgentTurnsStore"; +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; +import { scopeByChannel } from "@/features/agents/ui/agentSessionPanelLayout"; +import { + useAgentChatTitle, + useAgentsTranscript, +} from "@/features/agents/ui/useObserverEvents"; +import { ChatHeader } from "@/features/chat/ui/ChatHeader"; +import { + useSendChatContextMessageMutation, + useUpdateChatMetadataMutation, +} from "@/features/chats/hooks"; +import { + buildChatActivityPlacement, + shouldHidePersistedAgentMessage, +} from "@/features/chats/lib/chatActivity"; +import { chatProjectForMetadata } from "@/features/chats/lib/chatProjects"; +import { + buildChatCanvasContent, + deriveBranchTitle, + buildProjectSetupContext, + type ChatProject, + deriveChatTitle, + deriveConversationTitle, + NO_PROJECT_SELECTION_ID, +} from "@/features/chats/lib/chatSetup"; +import { ChatActivityTranscript } from "@/features/chats/ui/ChatActivityTranscript"; +import { + CHAT_AUTOMATION_TAG, + chatAutomationTag, + clearChatPinnedPr, +} from "@/features/chats/lib/chatWorkAutomation"; +import { + buildChatWorkContext, + markChatWorkContextSent, + mergeChatWorkBinding, + shouldShowChatWorkContextContent, + updateChatWorkBinding, + useChatWorkBinding, + wasChatWorkContextSent, +} from "@/features/chats/lib/chatWorkBinding"; +import { + collectBranchSourcesFromAgentMessages, + collectChatWorkBranchSources, + deriveBranchSourceFromAgentMessages, + deriveChatWorkBranchSource, +} from "@/features/chats/lib/chatWorkBranch"; +import { collectUnambiguousPullRequestSources } from "@/features/chats/lib/chatWorkLinks"; +import { cancelManagedAgentTurn } from "@/shared/api/agentControl"; +import { ChatWorkPanel } from "@/features/chats/ui/ChatWorkPanel"; +import { isHumanFacingAssistantText } from "@/features/chats/ui/chatActivityText"; +import { entranceClassForCreatedAt } from "@/features/chats/ui/messageEntrance"; +import { + AgentActivationCard, + ChatAutomationRow, + ChatContextRow, + ChatMessageRow, + ChatScrollAnchor, +} from "@/features/chats/ui/ChatConversationRows"; +import { ProjectPicker } from "@/features/chats/ui/QuickStartChat"; +import { MessageComposer } from "@/features/messages/ui/MessageComposer"; +import { setCanvas } from "@/shared/api/tauri"; +import type { + Channel, + ChannelTemplate, + ChatMetadata, + ManagedAgent, + RelayEvent, +} from "@/shared/api/types"; +import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + MessageScroller, + MessageScrollerButton, + MessageScrollerContent, + MessageScrollerItem, + MessageScrollerProvider, + MessageScrollerViewport, +} from "@/shared/ui/message-scroller"; +import { Spinner } from "@/shared/ui/spinner"; + +import type { UserProfileLookup } from "@/features/profile/lib/identity"; + +const CHAT_CONVERSATION_CLASS = "mx-auto w-full max-w-4xl px-4 sm:px-6 lg:px-8"; + +function eventHasTag(event: RelayEvent, name: string, value?: string) { + return event.tags.some( + (tag) => tag[0] === name && (value === undefined || tag[1] === value), + ); +} + +function chatContextTimelineRank(event: RelayEvent) { + if (!eventHasTag(event, "chat_context", "source")) { + return 0; + } + if (event.content.startsWith("Project setup")) { + return -1; + } + return 1; +} + +function workContextLine(content: string, label: "Branch" | "Pull request") { + const match = content.match(new RegExp(`^${label}:\\s*(.+)$`, "m")); + return match?.[1]?.trim() || null; +} + +function chatContextVisualTimestampMs({ + branchAnchors, + event, + prAnchors, +}: { + branchAnchors: ReadonlyMap; + event: RelayEvent; + prAnchors: ReadonlyMap; +}) { + if (!event.content.startsWith("Work context")) { + return event.created_at * 1_000; + } + + const branch = workContextLine(event.content, "Branch"); + if (branch) { + const branchAnchor = branchAnchors.get(branch); + if (branchAnchor !== undefined) { + return branchAnchor; + } + } + + const prHref = workContextLine(event.content, "Pull request"); + if (prHref) { + const prAnchor = prAnchors.get(prHref); + if (prAnchor !== undefined) { + return prAnchor; + } + } + + return event.created_at * 1_000; +} + +type ChatDetailProps = { + chat: Channel; + defaultAgent: ManagedAgent | null; + identityPubkey?: string; + isLoadingMessages: boolean; + isActivatingAgent: boolean; + isSending: boolean; + messages: RelayEvent[]; + metadata: ChatMetadata | null; + onActivateAgent: () => void; + onProjectCreated: (project: ChatProject) => void; + onSend: ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => Promise; + profiles?: UserProfileLookup; + projects: ChatProject[]; + shareAction?: React.ReactNode; + /** Show the top-right work module for this PR (toggled from the header). */ + showWorkPanel?: boolean; + templates: ChannelTemplate[]; + /** Latest PR link the chat's agent posted, if any. */ + workPanelHref?: string | null; +}; + +export function ChatDetail({ + chat, + defaultAgent, + identityPubkey, + isActivatingAgent, + isLoadingMessages, + isSending, + messages, + metadata, + onActivateAgent, + onProjectCreated, + onSend, + profiles, + projects, + shareAction, + showWorkPanel = true, + templates, + workPanelHref = null, +}: ChatDetailProps) { + const queryClient = useQueryClient(); + const updateMetadataMutation = useUpdateChatMetadataMutation(); + const sendContextMutation = useSendChatContextMessageMutation(); + // Every active managed agent, not just the default: a chat can have + // several agents working and all of their activity must render. + const managedAgentsQuery = useManagedAgentsQuery(); + // Key-stabilized: the managed-agents query refetches on a 30s interval and + // a fresh array identity would resubscribe the transcript store each time + // even when the active set is unchanged. + const activeAgentPubkeysKey = React.useMemo(() => { + const pubkeys = (managedAgentsQuery.data ?? []) + .filter(isManagedAgentActive) + .map((agent) => normalizePubkey(agent.pubkey)); + if (defaultAgent && isManagedAgentActive(defaultAgent)) { + pubkeys.push(normalizePubkey(defaultAgent.pubkey)); + } + return [...new Set(pubkeys)].sort().join(","); + }, [defaultAgent, managedAgentsQuery.data]); + const activeAgentPubkeys = React.useMemo( + () => activeAgentPubkeysKey.split(",").filter(Boolean), + [activeAgentPubkeysKey], + ); + const hasObserver = activeAgentPubkeys.length > 0; + const activeChannelTurns = useActiveAgentTurnsByChannel(); + // Per-turn ids, not a channel-wide boolean: while a new turn runs, older + // turn blocks must still render as completed (and never show their own + // "Working" marker). + const activeTurnIds = React.useMemo( + () => + new Set( + activeChannelTurns + .filter((turn) => turn.channelId === chat.id) + .flatMap((turn) => turn.turnIds), + ), + [activeChannelTurns, chat.id], + ); + const isChatTurnActive = activeTurnIds.size > 0; + const transcript = useAgentsTranscript(hasObserver, activeAgentPubkeys); + const scopedTranscript = React.useMemo( + () => scopeByChannel(transcript, chat.id), + [chat.id, transcript], + ); + const chatActivity = React.useMemo( + () => + buildChatActivityPlacement({ + agentPubkey: defaultAgent?.pubkey, + messages, + transcript: scopedTranscript, + }), + [defaultAgent?.pubkey, messages, scopedTranscript], + ); + // Branch the agent is on, straight from its worktree/checkout commands — + // the work panel shows it live, before any PR exists to report head.ref. + // Tool activity only exists from subscription time (observer frames are + // ephemeral), so fall back to the agent's persisted messages, which + // announce the branch ("…on branch kennylopez-dictation"). + const transcriptWorkBranchSource = React.useMemo( + () => deriveChatWorkBranchSource(scopedTranscript), + [scopedTranscript], + ); + const messageWorkBranchSource = React.useMemo( + () => deriveBranchSourceFromAgentMessages(messages, defaultAgent?.pubkey), + [defaultAgent?.pubkey, messages], + ); + const workBranch = + (transcriptWorkBranchSource ?? messageWorkBranchSource)?.branch ?? null; + const branchAnchors = React.useMemo(() => { + const anchors = new Map(); + const rememberEarliest = (branch: string, timestampMs: number | null) => { + if (timestampMs === null) { + return; + } + const current = anchors.get(branch); + if (current === undefined || timestampMs < current) { + anchors.set(branch, timestampMs); + } + }; + for (const source of collectBranchSourcesFromAgentMessages( + messages, + defaultAgent?.pubkey, + )) { + rememberEarliest(source.branch, source.timestampMs); + } + for (const source of collectChatWorkBranchSources(scopedTranscript)) { + rememberEarliest(source.branch, source.timestampMs); + } + return anchors; + }, [defaultAgent?.pubkey, messages, scopedTranscript]); + const prAnchors = React.useMemo(() => { + const anchors = new Map(); + for (const source of collectUnambiguousPullRequestSources(messages)) { + if (source.timestampMs === null) { + continue; + } + const current = anchors.get(source.href); + if (current === undefined || source.timestampMs < current) { + anchors.set(source.href, source.timestampMs); + } + } + return anchors; + }, [messages]); + const handleStopAgent = React.useCallback(() => { + // Cancel every agent with a live turn in this chat; fall back to the + // default agent when the turn store hasn't caught up yet. + const workingPubkeys = + activeChannelTurns.find((turn) => turn.channelId === chat.id) + ?.agentPubkeys ?? []; + const targets = + workingPubkeys.length > 0 + ? workingPubkeys + : defaultAgent?.pubkey + ? [defaultAgent.pubkey] + : []; + for (const pubkey of targets) { + cancelManagedAgentTurn(pubkey, chat.id).catch((error: unknown) => { + console.error("Failed to stop agent turn", error); + toast.error("Could not stop the agent"); + }); + } + }, [activeChannelTurns, chat.id, defaultAgent?.pubkey]); + const selectedProject = React.useMemo( + () => chatProjectForMetadata(metadata), + [metadata], + ); + const workBinding = useChatWorkBinding(chat.id); + const projectName = + metadata?.projectName?.trim() || selectedProject?.name?.trim() || null; + const projectPath = + metadata?.projectPath?.trim() || selectedProject?.path?.trim() || null; + + React.useEffect(() => { + if (metadata === null) { + return; + } + const hadProject = Boolean( + workBinding?.projectName || workBinding?.projectPath, + ); + const projectChanged = Boolean( + workBinding && + hadProject && + ((workBinding.projectName ?? "") !== (projectName ?? "") || + (workBinding.projectPath ?? "") !== (projectPath ?? "")), + ); + if (projectChanged) { + clearChatPinnedPr(chat.id); + updateChatWorkBinding( + chat.id, + { + projectName, + projectPath, + branch: null, + prHref: null, + prDetached: false, + }, + { replaceProject: true, replaceBranch: true, replacePr: true }, + ); + return; + } + updateChatWorkBinding( + chat.id, + { projectName, projectPath }, + { replaceProject: true }, + ); + }, [chat.id, metadata, projectName, projectPath, workBinding]); + + React.useEffect(() => { + if (!workBranch) { + return; + } + updateChatWorkBinding(chat.id, { branch: workBranch }); + }, [chat.id, workBranch]); + + React.useEffect(() => { + if (!workPanelHref) { + return; + } + updateChatWorkBinding(chat.id, { + prHref: workPanelHref, + prDetached: false, + }); + }, [chat.id, workPanelHref]); + + const boundBranch = workBinding?.branch ?? workBranch; + const boundPrHref = workBinding?.prDetached + ? null + : (workBinding?.prHref ?? workPanelHref); + const effectiveWorkBinding = React.useMemo( + () => + mergeChatWorkBinding( + null, + { + projectName, + projectPath, + branch: boundBranch, + prHref: boundPrHref, + prDetached: workBinding?.prDetached ?? false, + }, + { replaceProject: true, replaceBranch: true, replacePr: true }, + ), + [ + boundBranch, + boundPrHref, + projectName, + projectPath, + workBinding?.prDetached, + ], + ); + const workContext = React.useMemo( + () => buildChatWorkContext(effectiveWorkBinding), + [effectiveWorkBinding], + ); + const sendChatContextAsync = sendContextMutation.mutateAsync; + React.useEffect(() => { + if (!workContext || wasChatWorkContextSent(chat.id, workContext)) { + return; + } + let cancelled = false; + void sendChatContextAsync({ + channelId: chat.id, + content: workContext, + }) + .then(() => { + if (!cancelled) { + markChatWorkContextSent(chat.id, workContext); + } + }) + .catch((error) => { + console.warn("Failed to send chat work context", chat.id, error); + }); + return () => { + cancelled = true; + }; + }, [chat.id, sendChatContextAsync, workContext]); + const handleSelectProject = React.useCallback( + async (projectId: string | null) => { + const nextProject = + projectId && projectId !== NO_PROJECT_SELECTION_ID + ? (projects.find((project) => project.id === projectId) ?? null) + : null; + const nextTemplate = nextProject?.templateId + ? (templates.find( + (template) => template.id === nextProject.templateId, + ) ?? null) + : null; + const title = metadata?.title?.trim() || chat.name; + + try { + await updateMetadataMutation.mutateAsync({ + channelId: chat.id, + defaultAgentPubkey: + metadata?.defaultAgentPubkey ?? defaultAgent?.pubkey ?? undefined, + projectId: nextProject?.id, + projectName: nextProject?.name, + projectPath: nextProject?.path ?? undefined, + projectTemplateId: nextProject?.templateId ?? undefined, + source: metadata?.sourceChannelId + ? { + channelId: metadata.sourceChannelId, + eventId: metadata.sourceEventId ?? undefined, + threadRootId: metadata.sourceThreadRootId ?? undefined, + } + : undefined, + templateId: nextProject?.templateId ?? undefined, + title, + }); + + const leadingContent = buildProjectSetupContext({ + agent: defaultAgent, + project: nextProject, + templateName: nextTemplate?.name ?? null, + }); + const canvasContent = buildChatCanvasContent({ + channelName: title, + leadingContent, + template: nextTemplate, + }); + await setCanvas({ + channelId: chat.id, + content: canvasContent ?? "", + }); + await queryClient.invalidateQueries({ + queryKey: ["channel-canvas", chat.id], + }); + + if (leadingContent) { + await sendChatContextAsync({ + channelId: chat.id, + content: leadingContent, + }); + } + + if (nextProject) { + onProjectCreated({ + ...nextProject, + updatedAt: Math.floor(Date.now() / 1_000), + }); + } + toast.success( + nextProject + ? `Project set to ${nextProject.name}` + : "Project removed", + ); + } catch (error) { + console.error("Failed to update chat project", error); + toast.error("Could not update project", { + description: error instanceof Error ? error.message : undefined, + }); + } + }, + [ + chat.id, + chat.name, + defaultAgent, + metadata, + onProjectCreated, + projects, + queryClient, + sendChatContextAsync, + templates, + updateMetadataMutation, + ], + ); + const visibleMessages = React.useMemo( + () => + messages + .filter((message) => { + // Only true message kinds render: the channel query also delivers + // reactions/edits/deletions (kind 7 et al.), and a stray agent 👀 + // reaction otherwise renders as a tiny emoji bubble. + if ( + !CHANNEL_MESSAGE_EVENT_KINDS.includes( + message.kind as (typeof CHANNEL_MESSAGE_EVENT_KINDS)[number], + ) + ) { + return false; + } + // NOTE: no narration heuristics here. A persisted agent message was + // deliberately SENT to the channel — filtering it through the + // transcript's internal-narration patterns dropped real replies + // ("Done! I've sent the summary…"), leaving turns that visibly + // worked but never answered. Narration shaping belongs to the + // activity transcript; persisted rows only dedup against it. + return ( + (eventHasTag(message, "chat_context", "source") || + message.content.trim().length > 0) && + shouldShowChatWorkContextContent(message.content, workContext) && + !shouldHidePersistedAgentMessage({ + event: message, + hiddenAgentMessageIds: chatActivity.hiddenAgentMessageIds, + }) + ); + }) + .sort((left, right) => { + const leftTimestampMs = chatContextVisualTimestampMs({ + branchAnchors, + event: left, + prAnchors, + }); + const rightTimestampMs = chatContextVisualTimestampMs({ + branchAnchors, + event: right, + prAnchors, + }); + if (leftTimestampMs !== rightTimestampMs) { + return leftTimestampMs - rightTimestampMs; + } + return chatContextTimelineRank(left) - chatContextTimelineRank(right); + }), + [ + branchAnchors, + chatActivity.hiddenAgentMessageIds, + messages, + prAnchors, + workContext, + ], + ); + const hasTranscriptActivity = chatActivity.totalBlockCount > 0; + + // Solo chats (you + one agent) read as a plain stream: agent rows drop + // their avatar and name. Identities come back as soon as another agent or + // person participates, so multi-party chats stay attributable. + const showAgentIdentity = React.useMemo(() => { + const others = new Set(); + for (const message of messages) { + // Same message-kind allowlist as the timeline: a reaction event alone + // (an old harness's 👀) must not flip the solo layout to multi-party. + if ( + !CHANNEL_MESSAGE_EVENT_KINDS.includes( + message.kind as (typeof CHANNEL_MESSAGE_EVENT_KINDS)[number], + ) + ) { + continue; + } + const pubkey = normalizePubkey(message.pubkey); + if (identityPubkey && pubkey === normalizePubkey(identityPubkey)) { + continue; + } + others.add(pubkey); + } + if (defaultAgent?.pubkey) { + others.add(normalizePubkey(defaultAgent.pubkey)); + } + return others.size > 1; + }, [defaultAgent?.pubkey, identityPubkey, messages]); + + // Auto-title: upgrade a still-default title (the first message, verbatim) + // to a succinct subject line. A branch name is the strongest signal for + // work chats; otherwise prefer the agent-generated `chat_title` observer + // frame and fall back to the local heuristic once the conversation develops. + // Never touches a manually renamed chat, and skips shared chats we don't + // own. + const agentChatTitle = useAgentChatTitle(chat.id); + const heuristicRetitledChatIdsRef = React.useRef(new Set()); + const appliedTitleKeysRef = React.useRef(new Set()); + const updateChatMetadataAsync = updateMetadataMutation.mutateAsync; + React.useEffect(() => { + if (!metadata?.title) { + return; + } + if ( + metadata.authorPubkey && + identityPubkey && + normalizePubkey(metadata.authorPubkey) !== normalizePubkey(identityPubkey) + ) { + return; + } + + const firstOwnMessage = messages.find( + (message) => + identityPubkey != null && + normalizePubkey(message.pubkey) === normalizePubkey(identityPubkey) && + !eventHasTag(message, "chat_context", "source") && + message.content.trim().length > 0, + ); + if (!firstOwnMessage) { + return; + } + + const branchTitle = deriveBranchTitle(boundBranch); + const branchAutoTitles = new Set(); + if (branchTitle) { + branchAutoTitles.add(branchTitle); + } + for (const branch of branchAnchors.keys()) { + const candidate = deriveBranchTitle(branch); + if (candidate) { + branchAutoTitles.add(candidate); + } + } + + // Auto titles are the ones this flow (or chat creation) produced; any + // other value is a manual rename we must never override. + const isAutoTitle = + metadata.title === "New chat" || + metadata.title === deriveChatTitle(firstOwnMessage.content) || + metadata.title === deriveConversationTitle(firstOwnMessage.content) || + branchAutoTitles.has(metadata.title); + if (!isAutoTitle) { + return; + } + + let nextTitle: string | null = null; + let isHeuristicTitle = false; + if (branchTitle) { + nextTitle = branchTitle; + } else if (agentChatTitle && agentChatTitle.trim().length > 0) { + nextTitle = agentChatTitle.trim(); + } else if (!heuristicRetitledChatIdsRef.current.has(chat.id)) { + // Heuristic fallback. Retitling right after the first reply feels + // premature — wait until the conversation has developed: either a + // second exchange from the user, or the agent replying twice. + const agentReplyCount = messages.filter( + (message) => + defaultAgent?.pubkey != null && + normalizePubkey(message.pubkey) === + normalizePubkey(defaultAgent.pubkey) && + isHumanFacingAssistantText(message.content), + ).length; + const ownMessageCount = messages.filter( + (message) => + identityPubkey != null && + normalizePubkey(message.pubkey) === normalizePubkey(identityPubkey) && + !eventHasTag(message, "chat_context", "source") && + message.content.trim().length > 0, + ).length; + const conversationHasDeveloped = + agentReplyCount >= 2 || (agentReplyCount >= 1 && ownMessageCount >= 2); + if (conversationHasDeveloped) { + nextTitle = deriveConversationTitle(firstOwnMessage.content); + isHeuristicTitle = true; + } + } + + if (!nextTitle || nextTitle === metadata.title) { + return; + } + const applyKey = `${chat.id}:${nextTitle}`; + if (appliedTitleKeysRef.current.has(applyKey)) { + return; + } + appliedTitleKeysRef.current.add(applyKey); + if (isHeuristicTitle) { + heuristicRetitledChatIdsRef.current.add(chat.id); + } + + updateChatMetadataAsync({ + channelId: chat.id, + title: nextTitle, + defaultAgentPubkey: + metadata.defaultAgentPubkey ?? defaultAgent?.pubkey ?? undefined, + templateId: metadata.templateId ?? undefined, + projectId: metadata.projectId ?? undefined, + projectName: metadata.projectName ?? undefined, + projectPath: metadata.projectPath ?? undefined, + projectTemplateId: metadata.projectTemplateId ?? undefined, + source: metadata.sourceChannelId + ? { + channelId: metadata.sourceChannelId, + eventId: metadata.sourceEventId ?? undefined, + threadRootId: metadata.sourceThreadRootId ?? undefined, + } + : undefined, + }).catch((error) => { + console.warn("Failed to auto-title chat", chat.id, error); + // Retry on the next qualifying render rather than giving up for good. + appliedTitleKeysRef.current.delete(applyKey); + if (isHeuristicTitle) { + heuristicRetitledChatIdsRef.current.delete(chat.id); + } + }); + }, [ + agentChatTitle, + boundBranch, + branchAnchors, + chat.id, + defaultAgent?.pubkey, + identityPubkey, + messages, + metadata, + updateChatMetadataAsync, + ]); + const latestVisibleMessage = + visibleMessages.length > 0 + ? visibleMessages[visibleMessages.length - 1] + : null; + const latestVisibleMessageIsOwn = + latestVisibleMessage != null && + identityPubkey != null && + normalizePubkey(latestVisibleMessage.pubkey) === + normalizePubkey(identityPubkey); + const latestMessageActivityBlocks = + latestVisibleMessage != null + ? (chatActivity.blocksByMessageId.get(latestVisibleMessage.id) ?? []) + : []; + const latestOwnMessageNeedsAgent = + latestVisibleMessageIsOwn && + latestMessageActivityBlocks.length === 0 && + !isChatTurnActive; + // Keep activation pending until a live turn appears or the wait expires. + const ACTIVATION_PENDING_MS = 60_000; + const [isActivationPending, setIsActivationPending] = React.useState(false); + const handleActivateAgent = React.useCallback(() => { + setIsActivationPending(true); + onActivateAgent(); + }, [onActivateAgent]); + + // Automation prompts must not fail silently: surface send errors, and if + // the default agent is stopped, start it too — the backlog replay delivers + // the prompt once it connects. + const handleAutomationPrompt = React.useCallback( + (content: string, kind: "ci" | "comments") => { + onSend(content, [], [chatAutomationTag(kind)]).catch((error: unknown) => { + console.error("Failed to send automation prompt", error); + toast.error("Could not send the automation instructions"); + }); + if (defaultAgent != null && !isManagedAgentActive(defaultAgent)) { + setIsActivationPending(true); + onActivateAgent(); + } + }, + [defaultAgent, onActivateAgent, onSend], + ); + + const defaultAgentActive = + defaultAgent != null && isManagedAgentActive(defaultAgent); + React.useEffect(() => { + if (!isActivationPending) { + return; + } + if (isChatTurnActive || defaultAgentActive) { + setIsActivationPending(false); + return; + } + const timeout = window.setTimeout(() => { + setIsActivationPending(false); + }, ACTIVATION_PENDING_MS); + return () => window.clearTimeout(timeout); + }, [defaultAgentActive, isActivationPending, isChatTurnActive]); + // A stopped default agent always shows the card. + const defaultAgentInactive = defaultAgent != null && !defaultAgentActive; + const shouldShowAgentActivationCard = + (defaultAgentInactive && latestVisibleMessage != null) || + (!defaultAgentActive && latestOwnMessageNeedsAgent && !hasObserver); + const forceScrollSignature = latestVisibleMessageIsOwn + ? latestVisibleMessage.id + : null; + + return ( + <> + + +
+
+ + + + + {isLoadingMessages ? ( + +
+ + Loading messages +
+
+ ) : visibleMessages.length === 0 && !hasTranscriptActivity ? ( + +
+ +

+ No messages yet +

+

+ Send a message and Fizz will respond. +

+
+
+ ) : ( + <> + {visibleMessages.map((message) => { + const activityBlocks = + chatActivity.blocksByMessageId.get(message.id) ?? []; + const isContextMessage = eventHasTag( + message, + "chat_context", + "source", + ); + // Automation prompts stay invisible: the agent's + // activity and reply anchor here, but no bubble. + const isAutomationMessage = eventHasTag( + message, + CHAT_AUTOMATION_TAG[0], + CHAT_AUTOMATION_TAG[1], + ); + const isAgentMessage = + defaultAgent?.pubkey != null && + normalizePubkey(message.pubkey) === + normalizePubkey(defaultAgent.pubkey); + const isOwnMessage = + identityPubkey != null && + normalizePubkey(message.pubkey) === + normalizePubkey(identityPubkey); + + return ( + + + {isAutomationMessage ? ( + + ) : isContextMessage ? ( + + ) : ( + + )} + + {activityBlocks.length > 0 ? ( + + + + ) : null} + {shouldShowAgentActivationCard && + latestVisibleMessage?.id === message.id ? ( + + + + ) : null} + + ); + })} + {chatActivity.unplacedBlocks.length > 0 ? ( + + + + ) : null} + + )} +
+
+ + +
+
+ +
+ + } + /> +
+
+ +
+ + ); +} diff --git a/desktop/src/features/chats/ui/ChatHeaderActions.tsx b/desktop/src/features/chats/ui/ChatHeaderActions.tsx new file mode 100644 index 0000000000..34a769c194 --- /dev/null +++ b/desktop/src/features/chats/ui/ChatHeaderActions.tsx @@ -0,0 +1,481 @@ +import * as React from "react"; +import { + Bot, + Check, + FileText, + Link2, + MoreVertical, + Share2, +} from "lucide-react"; +import { toast } from "sonner"; + +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { ChannelCanvas } from "@/features/channels/ui/ChannelCanvas"; +import { useUpdateChatMetadataMutation } from "@/features/chats/hooks"; +import { buildDualOpenChatLink } from "@/features/chats/lib/chatLink"; +import { + cleanAssistantMessageText, + isHumanFacingAssistantText, +} from "@/features/chats/ui/chatActivityText"; +import { addChannelMembers, sendChannelMessage } from "@/shared/api/tauri"; +import type { + Channel, + ChatMetadata, + ManagedAgent, + RelayEvent, +} from "@/shared/api/types"; +import { + CHANNEL_MESSAGE_EVENT_KINDS, + KIND_SYSTEM_MESSAGE, +} from "@/shared/constants/kinds"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { Spinner } from "@/shared/ui/spinner"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +type ChatHeaderActionsProps = { + chat: Channel; + defaultAgentPubkey?: string | null; + messages: RelayEvent[]; + metadata: ChatMetadata | null; + /** Rendered between Share and the settings menu (work panel toggle). */ + workPanelToggle?: React.ReactNode; +}; + +export function ChatHeaderActions({ + chat, + defaultAgentPubkey, + messages, + metadata, + workPanelToggle, +}: ChatHeaderActionsProps) { + const [isCanvasOpen, setIsCanvasOpen] = React.useState(false); + const [isDefaultAgentOpen, setIsDefaultAgentOpen] = React.useState(false); + + return ( +
+ + + + {workPanelToggle} + setIsCanvasOpen(true)} + onOpenDefaultAgent={() => setIsDefaultAgentOpen(true)} + /> +
+ ); +} + +function ChatSettingsMenu({ + onOpenCanvas, + onOpenDefaultAgent, +}: { + onOpenCanvas: () => void; + onOpenDefaultAgent: () => void; +}) { + return ( + + + + + + + + Default agent + + + + Canvas settings + + + + ); +} + +function ChatCanvasDialog({ + chat, + onOpenChange, + open, +}: { + chat: Channel; + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + return ( + + + + Canvas + {chat.name} + + + + + ); +} + +function DefaultAgentDialog({ + chat, + defaultAgentPubkey, + metadata, + onOpenChange, + open, +}: { + chat: Channel; + defaultAgentPubkey?: string | null; + metadata: ChatMetadata | null; + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + const managedAgentsQuery = useManagedAgentsQuery({ enabled: open }); + const updateMetadataMutation = useUpdateChatMetadataMutation(); + const currentDefault = defaultAgentPubkey + ? normalizePubkey(defaultAgentPubkey) + : null; + const agents = React.useMemo( + () => + [...(managedAgentsQuery.data ?? [])].sort((left, right) => + left.name.localeCompare(right.name), + ), + [managedAgentsQuery.data], + ); + + const handleSelectAgent = React.useCallback( + async (agent: ManagedAgent) => { + try { + await addBotToChat(chat.id, agent.pubkey); + await updateMetadataMutation.mutateAsync({ + channelId: chat.id, + defaultAgentPubkey: agent.pubkey, + projectId: metadata?.projectId ?? undefined, + projectName: metadata?.projectName ?? undefined, + projectPath: metadata?.projectPath ?? undefined, + projectTemplateId: metadata?.projectTemplateId ?? undefined, + source: metadata?.sourceChannelId + ? { + channelId: metadata.sourceChannelId, + eventId: metadata.sourceEventId ?? undefined, + threadRootId: metadata.sourceThreadRootId ?? undefined, + } + : undefined, + templateId: metadata?.templateId ?? undefined, + title: metadata?.title ?? chat.name, + }); + toast.success(`Default agent set to ${agent.name}`); + onOpenChange(false); + } catch (error) { + console.error("Failed to update default chat agent", error); + toast.error("Could not update default agent", { + description: error instanceof Error ? error.message : undefined, + }); + } + }, + [chat.id, chat.name, metadata, onOpenChange, updateMetadataMutation], + ); + + return ( + + +
+ + Default agent + + Choose who replies when you send a message without an @mention. + + + +
+ {managedAgentsQuery.isLoading ? ( +
+ + Loading agents +
+ ) : agents.length === 0 ? ( +
+ No managed agents are available. +
+ ) : ( +
+ {agents.map((agent) => { + const isSelected = + currentDefault === normalizePubkey(agent.pubkey); + const isSaving = + updateMetadataMutation.isPending && + updateMetadataMutation.variables?.defaultAgentPubkey === + agent.pubkey; + + return ( + + ); + })} +
+ )} +
+
+
+
+ ); +} + +function ChatShareMenu({ + chat, + defaultAgentPubkey, + messages, + metadata, +}: ChatHeaderActionsProps) { + const [isSharingSummary, setIsSharingSummary] = React.useState(false); + const fallbackMessageId = React.useMemo( + () => latestChatLinkAnchorMessageId(messages), + [messages], + ); + const chatLink = React.useMemo( + () => + buildDualOpenChatLink({ + chatId: chat.id, + fallbackMessageId, + title: metadata?.title?.trim() || chat.name, + }), + [chat.id, chat.name, fallbackMessageId, metadata?.title], + ); + const canShareSummary = Boolean(metadata?.sourceChannelId); + + const handleCopyLink = React.useCallback(async () => { + try { + await navigator.clipboard.writeText(chatLink); + toast.success("Chat link copied"); + } catch (error) { + console.error("Failed to copy chat link", error); + toast.error("Could not copy link"); + } + }, [chatLink]); + + const handleShareSummary = React.useCallback(async () => { + const sourceChannelId = metadata?.sourceChannelId; + if (!sourceChannelId) { + return; + } + + setIsSharingSummary(true); + try { + const parentEventId = + metadata.sourceThreadRootId ?? metadata.sourceEventId ?? null; + await sendChannelMessage( + sourceChannelId, + buildSharedChatSummaryMessage({ + defaultAgentPubkey, + link: chatLink, + messages, + }), + parentEventId, + ); + toast.success("Summary shared"); + } catch (error) { + console.error("Failed to share chat summary", error); + toast.error("Could not share summary"); + } finally { + setIsSharingSummary(false); + } + }, [chatLink, defaultAgentPubkey, messages, metadata]); + + return ( + + + + + + void handleCopyLink()}> + + Copy link + + + void handleShareSummary()} + > + {isSharingSummary ? ( + + ) : ( + + )} + Share summary to source + + + + ); +} + +async function addBotToChat(channelId: string, pubkey: string) { + const result = await addChannelMembers({ + channelId, + pubkeys: [pubkey], + role: "bot", + }); + const error = result.errors.find( + (entry) => normalizePubkey(entry.pubkey) === normalizePubkey(pubkey), + ); + if (error && !error.error.toLowerCase().includes("already")) { + throw new Error(error.error); + } +} + +function buildSharedChatSummaryMessage({ + defaultAgentPubkey, + link, + messages, +}: { + defaultAgentPubkey?: string | null; + link: string; + messages: RelayEvent[]; +}) { + const summary = summarizeChat(messages, defaultAgentPubkey); + return [ + link, + summary + ? `> ${summary.replace(/\n+/g, "\n> ")}` + : "> No summary is available yet.", + ].join("\n"); +} + +function summarizeChat( + messages: RelayEvent[], + defaultAgentPubkey?: string | null, +) { + const normalizedAgentPubkey = defaultAgentPubkey + ? normalizePubkey(defaultAgentPubkey) + : null; + const latestAssistantMessage = [...messages].reverse().find((message) => { + if (!normalizedAgentPubkey) { + return false; + } + if (message.kind === KIND_SYSTEM_MESSAGE) { + return false; + } + if (normalizePubkey(message.pubkey) !== normalizedAgentPubkey) { + return false; + } + return isHumanFacingAssistantText(message.content); + }); + + const text = latestAssistantMessage + ? cleanAssistantMessageText(latestAssistantMessage.content) + : messages + .filter( + (message) => + message.kind !== KIND_SYSTEM_MESSAGE && + !eventHasTag(message, "chat_context", "source") && + message.content.trim().length > 0, + ) + .slice(-3) + .map((message) => message.content.trim()) + .join("\n"); + + return truncateSummary(text); +} + +function latestChatLinkAnchorMessageId(messages: RelayEvent[]) { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if ( + message?.id && + CHANNEL_MESSAGE_EVENT_KINDS.includes( + message.kind as (typeof CHANNEL_MESSAGE_EVENT_KINDS)[number], + ) + ) { + return message.id; + } + } + return null; +} + +function eventHasTag(event: RelayEvent, name: string, value?: string) { + return event.tags.some( + (tag) => tag[0] === name && (value === undefined || tag[1] === value), + ); +} + +function truncateSummary(value: string) { + const normalized = value.trim().replace(/\n{3,}/g, "\n\n"); + if (normalized.length <= 900) { + return normalized; + } + return `${normalized.slice(0, 897).trimEnd()}...`; +} diff --git a/desktop/src/features/chats/ui/ChatList.tsx b/desktop/src/features/chats/ui/ChatList.tsx new file mode 100644 index 0000000000..5c3b13a26e --- /dev/null +++ b/desktop/src/features/chats/ui/ChatList.tsx @@ -0,0 +1,321 @@ +// @ts-nocheck +import * as React from "react"; +import { + ChevronDown, + ChevronRight, + MoreVertical, + Notebook, + Plus, +} from "lucide-react"; + +import { useActiveAgentTurnsByChannel } from "@/features/agents/activeAgentTurnsStore"; +import type { buildChatProjects } from "@/features/chats/lib/chatProjects"; +import { ChatListHeader, ChatListItem } from "@/features/chats/ui/ChatListItem"; +import { ChatListSectionHeader } from "@/features/chats/ui/ChatListSectionHeader"; +import { ChatListSkeleton } from "@/features/chats/ui/ChatListSkeleton"; +import { ChatProjectDialog } from "@/features/chats/ui/ChatProjectDialog"; +import { isSharedChatMetadata } from "@/features/chats/lib/chatShared"; +import type { + Channel, + ChannelTemplate, + ChatMetadata, +} from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +export function ChatList({ + archivingChatId, + chats, + getChannelReadAt, + identityPubkey, + isLoading, + metadataByChatId, + onArchiveChat, + onCreateChat, + onCreateProjectChat, + onRenameChat, + onSelectChat, + onTogglePin, + onUpdateProject, + pinnedChatIds, + projects, + readStateVersion: _readStateVersion, + selectedChatId, + templates, + unreadChannelCounts, + unreadChannelIds, +}: { + archivingChatId: string | null; + chats: Channel[]; + getChannelReadAt: (channelId: string) => number | null; + identityPubkey?: string | null; + isLoading: boolean; + metadataByChatId: ReadonlyMap; + onArchiveChat: (chatId: string) => void; + onCreateChat: () => void; + onCreateProjectChat: (projectId: string) => void; + onRenameChat: (chatId: string) => void; + onSelectChat: (chatId: string) => void; + onTogglePin: (chatId: string) => void; + pinnedChatIds: ReadonlySet; + onUpdateProject: ( + project: ReturnType[number], + ) => void; + projects: ReturnType; + readStateVersion: number; + selectedChatId: string | null; + templates: ChannelTemplate[]; + unreadChannelCounts: ReadonlyMap; + unreadChannelIds: ReadonlySet; +}) { + const [collapsedProjectIds, setCollapsedProjectIds] = React.useState( + () => new Set(), + ); + const [editingProject, setEditingProject] = React.useState< + ReturnType[number] | null + >(null); + const [isCreateProjectOpen, setIsCreateProjectOpen] = React.useState(false); + const activeAgentTurnsByChannel = useActiveAgentTurnsByChannel(); + const activeChatIds = React.useMemo( + () => new Set(activeAgentTurnsByChannel.map((turn) => turn.channelId)), + [activeAgentTurnsByChannel], + ); + const chatsByProject = React.useMemo(() => { + const groups = new Map(); + const unprojected: Channel[] = []; + const shared: Channel[] = []; + const knownProjectIds = new Set(projects.map((project) => project.id)); + for (const chat of chats) { + const metadata = metadataByChatId.get(chat.id); + if (isSharedChatMetadata(metadata, identityPubkey)) { + shared.push(chat); + continue; + } + const projectId = metadata?.projectId; + if (projectId && knownProjectIds.has(projectId)) { + const group = groups.get(projectId) ?? []; + group.push(chat); + groups.set(projectId, group); + } else { + unprojected.push(chat); + } + } + const pinnedFirst = (list: Channel[]) => + [...list].sort( + (left, right) => + Number(pinnedChatIds.has(right.id)) - + Number(pinnedChatIds.has(left.id)), + ); + return { + groups: new Map( + [...groups.entries()].map(([projectId, group]) => [ + projectId, + pinnedFirst(group), + ]), + ), + shared: pinnedFirst(shared), + unprojected: pinnedFirst(unprojected), + }; + }, [chats, identityPubkey, metadataByChatId, pinnedChatIds, projects]); + + const toggleProject = React.useCallback((projectId: string) => { + setCollapsedProjectIds((current) => { + const next = new Set(current); + if (next.has(projectId)) { + next.delete(projectId); + } else { + next.add(projectId); + } + return next; + }); + }, []); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + return ( +
+ +
+
+ setIsCreateProjectOpen(true)} + /> + {projects.map((project) => { + const projectChats = chatsByProject.groups.get(project.id) ?? []; + const isCollapsed = collapsedProjectIds.has(project.id); + return ( +
+
+ + + + + + + + setEditingProject(project)} + > + Project settings + + + + +
+ {!isCollapsed ? ( +
+ {projectChats.length > 0 ? ( + projectChats.map((chat) => ( + + )) + ) : ( +
+ No chats yet +
+ )} +
+ ) : null} +
+ ); + })} +
+
+ + {chatsByProject.unprojected.length > 0 ? ( + chatsByProject.unprojected.map((chat) => ( + + )) + ) : ( +
+ No chats yet +
+ )} +
+ {chatsByProject.shared.length > 0 ? ( +
+ + {chatsByProject.shared.map((chat) => ( + + ))} +
+ ) : null} +
+ { + onUpdateProject(project); + setIsCreateProjectOpen(false); + }} + open={isCreateProjectOpen} + templates={templates} + /> + { + if (!open) { + setEditingProject(null); + } + }} + onSaveProject={(project) => { + onUpdateProject(project); + setEditingProject(null); + }} + open={editingProject !== null} + project={editingProject} + templates={templates} + /> +
+ ); +} diff --git a/desktop/src/features/chats/ui/ChatListItem.tsx b/desktop/src/features/chats/ui/ChatListItem.tsx new file mode 100644 index 0000000000..71d8b5df6a --- /dev/null +++ b/desktop/src/features/chats/ui/ChatListItem.tsx @@ -0,0 +1,184 @@ +import { Archive, Pencil, Pin, PinOff } from "lucide-react"; + +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/shared/ui/context-menu"; + +export function ChatListHeader() { + return ( +
+
+
+ ); +} + +export function ChatListItem({ + canRename = true, + chat, + displayName, + getChannelReadAt, + isAgentRunning = false, + isArchiving = false, + isPinned = false, + onArchiveChat, + onRenameChat, + onSelectChat, + onTogglePin, + selectedChatId, + unreadChannelCounts, + unreadChannelIds, +}: { + /** Renaming writes owner metadata — disabled for shared chats. */ + canRename?: boolean; + chat: Channel; + /** Preferred label (chat metadata title); falls back to the channel name. */ + displayName?: string | null; + getChannelReadAt: (channelId: string) => number | null; + isAgentRunning?: boolean; + isArchiving?: boolean; + isPinned?: boolean; + onArchiveChat?: (chatId: string) => void; + onRenameChat?: (chatId: string) => void; + onSelectChat: (chatId: string) => void; + onTogglePin?: (chatId: string) => void; + selectedChatId: string | null; + unreadChannelCounts: ReadonlyMap; + unreadChannelIds: ReadonlySet; +}) { + const name = displayName?.trim() || chat.name; + const isUnread = unreadChannelIds.has(chat.id); + const unreadCount = unreadChannelCounts.get(chat.id) ?? 0; + const readAt = getChannelReadAt(chat.id); + const lastMessageAt = chat.lastMessageAt + ? Math.floor(Date.parse(chat.lastMessageAt) / 1_000) + : null; + const hasUnread = + isUnread || + (readAt !== null && lastMessageAt !== null && lastMessageAt > readAt); + + const isSelected = selectedChatId === chat.id; + + const row = ( +
+ + {onArchiveChat ? ( + // Zero-width until hover/focus so the title gets the full row; the + // slot appears instantly to make room for the archive button. +
+ +
+ ) : null} +
+ ); + + if (!onRenameChat && !onTogglePin && !onArchiveChat) { + return row; + } + + return ( + + {row} + + {onRenameChat && canRename ? ( + { + // Defer past the menu's close/focus-restore: opening a dialog + // in the same tick wrestles Radix's focus management and can + // freeze the webview (observed as an app "crash" on rename). + window.setTimeout(() => onRenameChat(chat.id), 0); + }} + > + + Rename chat + + ) : null} + {onTogglePin ? ( + onTogglePin(chat.id)}> + {isPinned ? ( + + ) : ( + + )} + {isPinned ? "Unpin chat" : "Pin chat"} + + ) : null} + {onArchiveChat ? ( + <> + + onArchiveChat(chat.id)} + > + + Archive chat + + + ) : null} + + + ); +} diff --git a/desktop/src/features/chats/ui/ChatListSectionHeader.tsx b/desktop/src/features/chats/ui/ChatListSectionHeader.tsx new file mode 100644 index 0000000000..6915439640 --- /dev/null +++ b/desktop/src/features/chats/ui/ChatListSectionHeader.tsx @@ -0,0 +1,33 @@ +import { Plus } from "lucide-react"; + +import { Button } from "@/shared/ui/button"; + +type ChatListSectionHeaderProps = { + actionLabel?: string; + label: string; + onAction?: () => void; +}; + +export function ChatListSectionHeader({ + actionLabel, + label, + onAction, +}: ChatListSectionHeaderProps) { + return ( +
+ {label} + {onAction ? ( + + ) : null} +
+ ); +} diff --git a/desktop/src/features/chats/ui/ChatListSkeleton.tsx b/desktop/src/features/chats/ui/ChatListSkeleton.tsx new file mode 100644 index 0000000000..b47b60dfaf --- /dev/null +++ b/desktop/src/features/chats/ui/ChatListSkeleton.tsx @@ -0,0 +1,53 @@ +import { cn } from "@/shared/lib/cn"; +import { Skeleton } from "@/shared/ui/skeleton"; + +const chatListSkeletonRows = [ + { key: "project-primary", width: "w-32" }, + { key: "project-secondary", width: "w-24" }, + { key: "project-tertiary", width: "w-36" }, + { key: "chat-primary", width: "w-28" }, + { key: "chat-secondary", width: "w-20" }, +] as const; + +export function ChatListSkeleton() { + return ( +
+
+
+ + + +
+
+ {chatListSkeletonRows.slice(0, 3).map((row) => ( +
+ +
+ ))} +
+
+
+
+ + +
+ {chatListSkeletonRows.slice(3).map((row) => ( +
+ +
+ ))} +
+
+ ); +} diff --git a/desktop/src/features/chats/ui/ChatProjectDialog.tsx b/desktop/src/features/chats/ui/ChatProjectDialog.tsx new file mode 100644 index 0000000000..132a04c32f --- /dev/null +++ b/desktop/src/features/chats/ui/ChatProjectDialog.tsx @@ -0,0 +1,218 @@ +import * as React from "react"; +import { FolderOpen, Sparkles } from "lucide-react"; +import { toast } from "sonner"; + +import { + type ChatProject, + makeChatProjectId, +} from "@/features/chats/lib/chatSetup"; +import { pickChatProjectDirectory } from "@/shared/api/tauriChats"; +import type { ChannelTemplate } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Spinner } from "@/shared/ui/spinner"; + +type ChatProjectDialogProps = { + mode?: "create" | "edit"; + onOpenChange: (open: boolean) => void; + onSaveProject: (project: ChatProject) => void; + open: boolean; + project?: ChatProject | null; + templates: ChannelTemplate[]; +}; + +function errorMessage(error: unknown) { + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + if (typeof error === "string" && error.trim()) { + return error.trim(); + } + return "Unknown error"; +} + +export function ChatProjectDialog({ + mode = "create", + onOpenChange, + onSaveProject, + open, + project = null, + templates, +}: ChatProjectDialogProps) { + const [name, setName] = React.useState(""); + const [path, setPath] = React.useState(""); + const [templateId, setTemplateId] = React.useState(""); + const [isChoosingFolder, setIsChoosingFolder] = React.useState(false); + const selectedTemplate = + templates.find((template) => template.id === templateId) ?? null; + const canSave = name.trim().length > 0; + + React.useEffect(() => { + if (!open) { + return; + } + setName(project?.name ?? ""); + setPath(project?.path ?? ""); + setTemplateId(project?.templateId ?? ""); + }, [open, project]); + + const handleSave = React.useCallback(() => { + if (!canSave) { + return; + } + onSaveProject({ + id: project?.id ?? makeChatProjectId(), + name: name.trim(), + path: path.trim() || null, + templateId: templateId || null, + updatedAt: Math.floor(Date.now() / 1_000), + chatCount: project?.chatCount ?? 0, + }); + onOpenChange(false); + }, [canSave, name, onOpenChange, onSaveProject, path, project, templateId]); + + const handleChooseFolder = React.useCallback(async () => { + setIsChoosingFolder(true); + try { + const selectedPath = await pickChatProjectDirectory(); + if (!selectedPath) { + return; + } + setPath(selectedPath); + if (!name.trim()) { + setName(projectNameFromPath(selectedPath)); + } + } catch (error) { + console.error("Failed to choose project folder", error); + toast.error("Could not choose folder", { + description: errorMessage(error), + }); + } finally { + setIsChoosingFolder(false); + } + }, [name]); + + const isEdit = mode === "edit"; + + return ( + + + + + {isEdit ? "Project settings" : "Add new project"} + + + {isEdit + ? "Change the name, folder, and default template for new chats." + : "Name the work and choose the folder Fizz should treat as its home."} + + +
+
+ + setName(event.target.value)} + placeholder="Project name" + value={name} + /> +
+
+ +
+ setPath(event.target.value)} + placeholder="/Users/me/Development/project" + value={path} + /> + +
+
+
+
Use template
+
+ setTemplateId("")} + /> + {templates.map((template) => ( + setTemplateId(template.id)} + /> + ))} +
+
+
+ + + + +
+
+ ); +} + +function ProjectTemplateRow({ + checked, + label, + onSelect, +}: { + checked: boolean; + label: string; + onSelect: () => void; +}) { + return ( + + ); +} + +function projectNameFromPath(path: string) { + const normalized = path.trim().replace(/[\\/]+$/, ""); + return normalized.split(/[\\/]/).filter(Boolean).pop() ?? "New project"; +} diff --git a/desktop/src/features/chats/ui/ChatRenameDialog.tsx b/desktop/src/features/chats/ui/ChatRenameDialog.tsx new file mode 100644 index 0000000000..fae16572dc --- /dev/null +++ b/desktop/src/features/chats/ui/ChatRenameDialog.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; + +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; + +export function ChatRenameDialog({ + currentTitle, + isSaving = false, + onOpenChange, + onRename, + open, +}: { + currentTitle: string; + isSaving?: boolean; + onOpenChange: (open: boolean) => void; + onRename: (title: string) => void; + open: boolean; +}) { + const [title, setTitle] = React.useState(currentTitle); + + React.useEffect(() => { + if (open) { + setTitle(currentTitle); + } + }, [currentTitle, open]); + + const trimmed = title.trim(); + const canSave = trimmed.length > 0 && !isSaving; + + return ( + + + + Rename chat + + The new name replaces the auto-generated title. + + +
{ + event.preventDefault(); + if (canSave) { + onRename(trimmed); + } + }} + > + setTitle(event.target.value)} + placeholder="Chat name" + value={title} + /> + + + + +
+
+
+ ); +} diff --git a/desktop/src/features/chats/ui/ChatStartPresets.tsx b/desktop/src/features/chats/ui/ChatStartPresets.tsx new file mode 100644 index 0000000000..f67bc25fc6 --- /dev/null +++ b/desktop/src/features/chats/ui/ChatStartPresets.tsx @@ -0,0 +1,421 @@ +import * as React from "react"; +import { + Bot, + Check, + Notebook, + NotepadTextDashed, + Search, + UserPlus, + Users, + X, +} from "lucide-react"; + +import type { ChatProject } from "@/features/chats/lib/chatSetup"; +import { useUserSearchQuery } from "@/features/profile/hooks"; +import type { AgentTeam, ManagedAgent } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +/** Which agent(s) the new chat starts with. */ +export type ChatAgentPreset = + | { kind: "default" } + | { kind: "agent"; agent: ManagedAgent } + | { kind: "team"; team: AgentTeam }; + +export type ChatInvitee = { + pubkey: string; + displayName: string | null; + avatarUrl: string | null; +}; + +export function chatAgentPresetLabel( + preset: ChatAgentPreset, + defaultAgentName: string, +) { + if (preset.kind === "agent") { + return preset.agent.name; + } + if (preset.kind === "team") { + return preset.team.name; + } + return defaultAgentName; +} + +/** + * Preset cards for the new-chat screen — same container language as the + * channel-intro action cards: default agent (swap to another agent or a + * team), the project's working directory, and pre-invited people. + */ +export function ChatStartPresets({ + agentPreset, + agents, + defaultAgentName, + invited, + onAgentPresetChange, + onInvitedChange, + projectCard, + teams, +}: { + agentPreset: ChatAgentPreset; + agents: ManagedAgent[]; + defaultAgentName: string; + invited: ChatInvitee[]; + onAgentPresetChange: (preset: ChatAgentPreset) => void; + onInvitedChange: (invited: ChatInvitee[]) => void; + /** Rendered as the middle card — the project picker owns its popover. */ + projectCard: React.ReactNode; + teams: AgentTeam[]; +}) { + return ( +
+ + {projectCard} + +
+ ); +} + +type PresetCardProps = React.ComponentPropsWithoutRef<"button"> & { + icon: React.ReactNode; + subtitle: string; + testId?: string; + title: string; +}; + +// Plain-prop spread + ref forwarding so Radix `asChild` triggers can drive +// the card (popover click/aria props arrive via ...props). +export function PresetCard({ + icon, + subtitle, + testId, + title, + ...props +}: PresetCardProps) { + return ( + + ); +} + +function PickerRow({ + checked, + icon, + label, + meta, + onSelect, +}: { + checked?: boolean; + icon: React.ReactNode; + label: string; + meta?: string | null; + onSelect: () => void; +}) { + return ( + + ); +} + +function AgentPresetCard({ + agentPreset, + agents, + defaultAgentName, + onAgentPresetChange, + teams, +}: { + agentPreset: ChatAgentPreset; + agents: ManagedAgent[]; + defaultAgentName: string; + onAgentPresetChange: (preset: ChatAgentPreset) => void; + teams: AgentTeam[]; +}) { + const [open, setOpen] = React.useState(false); + const selectedAgent = agentPreset.kind === "agent" ? agentPreset.agent : null; + const icon = + agentPreset.kind === "team" ? ( + + ) : selectedAgent?.avatarUrl ? ( + + ) : ( + + ); + + return ( + + + + + +
+ } + label={defaultAgentName} + meta="Default agent" + onSelect={() => { + onAgentPresetChange({ kind: "default" }); + setOpen(false); + }} + /> + {agents.map((agent) => ( + + } + key={agent.pubkey} + label={agent.name} + meta={ + agent.status === "running" || agent.status === "deployed" + ? "Running" + : "Stopped" + } + onSelect={() => { + onAgentPresetChange({ kind: "agent", agent }); + setOpen(false); + }} + /> + ))} + {teams.length > 0 ? ( + <> +
+
+ Teams +
+ {teams.map((team) => ( + } + key={team.id} + label={team.name} + meta={ + team.personaIds.length === 1 + ? "1 agent" + : `${team.personaIds.length} agents` + } + onSelect={() => { + onAgentPresetChange({ kind: "team", team }); + setOpen(false); + }} + /> + ))} + + ) : null} +
+ + + ); +} + +export function ProjectPresetCard({ + isNoProjectSelected, + selectedProject, + ...props +}: { + isNoProjectSelected: boolean; + selectedProject: ChatProject | null; +} & React.ComponentPropsWithoutRef<"button">) { + return ( + + ) : ( + + ) + } + subtitle={ + selectedProject + ? (selectedProject.path ?? "No directory") + : isNoProjectSelected + ? "Free chat — no directory" + : "Pick a project" + } + testId="chat-preset-directory" + title={selectedProject ? selectedProject.name : "No project"} + /> + ); +} + +function InviteCard({ + invited, + onInvitedChange, +}: { + invited: ChatInvitee[]; + onInvitedChange: (invited: ChatInvitee[]) => void; +}) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + // Same search path as the channel invite (MembersSidebar): shared hook, + // normalized query, cached results. + const deferredQuery = React.useDeferredValue(query.trim()); + const searchQuery = useUserSearchQuery(deferredQuery, { + enabled: open && deferredQuery.length > 0, + limit: 8, + }); + const results = searchQuery.data ?? []; + const isSearching = searchQuery.isFetching; + + const invitedPubkeys = new Set( + invited.map((person) => normalizePubkey(person.pubkey)), + ); + + return ( + + + } + subtitle={ + invited.length === 0 + ? "Add someone to the chat" + : invited + .map((person) => person.displayName ?? "someone") + .join(", ") + } + testId="chat-preset-invite" + title={invited.length === 0 ? "Invite" : `${invited.length} invited`} + /> + + +
+ + setQuery(event.target.value)} + placeholder="Search people" + value={query} + /> +
+ {invited.length > 0 ? ( +
+ {invited.map((person) => ( +
+ + + {person.displayName ?? person.pubkey.slice(0, 8)} + + +
+ ))} +
+ ) : null} +
+ {results + .filter((user) => !invitedPubkeys.has(normalizePubkey(user.pubkey))) + .map((user) => ( + + } + key={user.pubkey} + label={user.displayName ?? user.pubkey.slice(0, 8)} + meta={user.nip05Handle} + onSelect={() => { + onInvitedChange([ + ...invited, + { + pubkey: user.pubkey, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + }, + ]); + setQuery(""); + }} + /> + ))} + {query.trim() && !isSearching && results.length === 0 ? ( +
+ No people found +
+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx new file mode 100644 index 0000000000..7939d8c771 --- /dev/null +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -0,0 +1,586 @@ +import * as React from "react"; +import { toast } from "sonner"; +import { + ChevronDown, + CircleCheck, + CircleDashed, + CircleX, + GitBranch, + LoaderCircle, + MessageSquareText, + Pin, +} from "lucide-react"; + +import { + CHAT_PR_UNPINNED, + clearChatPinnedPr, + type ChatPinnedPr, + readChatPinnedPr, + updateChatWorkAutomation, + useChatWorkAutomation, + writeChatPinnedPr, +} from "@/features/chats/lib/chatWorkAutomation"; +import { + clearChatWorkBindingPr, + updateChatWorkBinding, +} from "@/features/chats/lib/chatWorkBinding"; +import { + type GithubCheckSummary, + parseGithubPullRequestRef, + useGithubCheckSummaryQuery, + useGithubCommentStateQuery, + useGithubPrForBranchQuery, + useGithubPullRequestQuery, +} from "@/shared/lib/githubPullRequest"; +import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview"; +import { cn } from "@/shared/lib/cn"; +import { AnimatedTitleText } from "@/shared/ui/animated-title-text"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Checkbox } from "@/shared/ui/checkbox"; +import { GithubPullRequestCard } from "@/shared/ui/link-preview-attachment"; + +const CHIP_CLASS = + "flex items-center gap-1.5 rounded-2xl border border-border/70 bg-muted/30 px-3 py-2.5 text-xs"; + +// Last branch/PR the panel showed per chat. Async sources (the PR query) +// resolve after mount, and without this the chip replays its swap animation +// and the card replays its pop-in on every visit to the chat — the +// animations should mark NEW information, not navigation. +const lastShownBranchByChat = new Map(); +const lastShownPrByChat = new Map(); + +// A consumed watermark must not strand a persisting condition: if CI is +// still red (or comments still open) and NO turn is running, armed +// automation re-nudges after this cooldown — the earlier nudge may have +// landed while the agent was stopped or the message failed to take. +const RENUDGE_COOLDOWN_MS = 15 * 60_000; + +type PinnedPrState = { + chatId: string; + pinned: ChatPinnedPr | null; +}; + +/** + * Right-hand work drawer for a chat: branch, live PR card, and a CI monitor + * once the agent has produced a pull request; an empty state before that. + * The monitor expands to the individual check runs and the automation + * toggles — when armed, CI failures and newly-open review threads prompt the + * chat's agent automatically (deduped per head sha / open-thread watermark). + */ +export function ChatWorkPanel({ + agentName = "Fizz", + branch = null, + chatId, + isTurnActive = false, + onAutomationPrompt, + open = true, + prHref, + projectPath = null, +}: { + /** Default agent's display name — named in the automation feedback. */ + agentName?: string; + /** Live branch from the agent's worktree/checkout activity, if any. */ + branch?: string | null; + chatId: string; + /** Whether an agent turn is currently running in this chat. */ + isTurnActive?: boolean; + onAutomationPrompt?: (content: string, kind: "ci" | "comments") => void; + open?: boolean; + prHref?: string | null; + /** Project directory — enables PR discovery by branch via the git remote. */ + projectPath?: string | null; +}) { + // No PR link in the chat yet? Discover it from the branch via the + // project's git remote (agents don't always paste the URL). All GitHub + // polling pauses while the drawer is closed — a hidden panel burning the + // API rate limit is how the monitor went stale-empty. + const automation = useChatWorkAutomation(chatId); + // Armed automation keeps watching with the drawer closed; otherwise a + // hidden panel stops polling entirely. + const monitorActive = + open || automation.autoFixCi || automation.addressComments; + // Pin resolution order: a MANUAL pin outranks everything (the user said + // "this is the PR"), then a link posted in THIS chat, then the remembered + // auto pin, then branch discovery — discovery alone is ambiguous when + // agents reuse a worktree across chats in one project. + const [pinnedState, setPinnedState] = React.useState(() => ({ + chatId, + pinned: readChatPinnedPr(chatId), + })); + const pinned = + pinnedState.chatId === chatId + ? pinnedState.pinned + : readChatPinnedPr(chatId); + React.useEffect(() => { + setPinnedState({ chatId, pinned: readChatPinnedPr(chatId) }); + }, [chatId]); + // The empty-string sentinel means "user unlinked — no PR for this chat": + // discovery stays off, posted links still win. + const isUnpinned = pinned?.href === CHAT_PR_UNPINNED && pinned.manual; + const manualHref = pinned?.manual && pinned.href ? pinned.href : null; + const autoPinnedHref = !pinned?.manual && pinned?.href ? pinned.href : null; + const discoveredPrQuery = useGithubPrForBranchQuery( + monitorActive && !prHref && !manualHref && !autoPinnedHref && !isUnpinned + ? projectPath + : null, + branch, + ); + const effectiveHref = + manualHref ?? + prHref ?? + autoPinnedHref ?? + (isUnpinned ? null : (discoveredPrQuery.data ?? null)); + React.useEffect(() => { + // Remember PR links posted in this chat, but never persist branch + // discovery: discovery is useful as a live hint and too ambiguous to + // become durable chat ownership. + const current = readChatPinnedPr(chatId); + if (current?.manual) { + return; + } + if (prHref && prHref !== current?.href) { + writeChatPinnedPr(chatId, prHref); + updateChatWorkBinding( + chatId, + { prHref, prDetached: false }, + { replacePr: false }, + ); + setPinnedState({ chatId, pinned: { href: prHref, manual: false } }); + } + }, [chatId, prHref]); + const handleUnlinkPr = React.useCallback(() => { + writeChatPinnedPr(chatId, CHAT_PR_UNPINNED, true); + updateChatWorkBinding( + chatId, + { prHref: null, prDetached: true }, + { replacePr: true }, + ); + setPinnedState({ + chatId, + pinned: { href: CHAT_PR_UNPINNED, manual: true }, + }); + }, [chatId]); + const [isPinEditorOpen, setIsPinEditorOpen] = React.useState(false); + const [pinInput, setPinInput] = React.useState(""); + const handlePinSubmit = React.useCallback(() => { + const trimmed = pinInput.trim(); + if (!parseGithubPullRequestRef(trimmed)) { + toast.error("Enter a full GitHub pull request URL"); + return; + } + writeChatPinnedPr(chatId, trimmed, true); + updateChatWorkBinding( + chatId, + { prHref: trimmed, prDetached: false }, + { replacePr: true }, + ); + setPinnedState({ chatId, pinned: { href: trimmed, manual: true } }); + setIsPinEditorOpen(false); + setPinInput(""); + }, [chatId, pinInput]); + const preview = effectiveHref + ? parseSupportedLinkPreview(effectiveHref) + : null; + const parsedRef = effectiveHref + ? parseGithubPullRequestRef(effectiveHref) + : null; + const ref = monitorActive ? parsedRef : null; + const prQuery = useGithubPullRequestQuery(ref); + const pr = prQuery.data ?? null; + React.useEffect(() => { + const pinnedHref = !pinned?.manual && pinned?.href ? pinned.href : null; + const normalizedBranch = branch?.trim(); + const prHeadRef = pr?.headRef?.trim(); + if ( + !pinnedHref || + effectiveHref !== pinnedHref || + !normalizedBranch || + !prHeadRef || + prHeadRef === normalizedBranch + ) { + return; + } + + clearChatPinnedPr(chatId); + clearChatWorkBindingPr(chatId, pinnedHref); + setPinnedState({ chatId, pinned: null }); + }, [branch, chatId, effectiveHref, pinned, pr?.headRef]); + const checksQuery = useGithubCheckSummaryQuery(ref, pr?.headSha); + const checks = checksQuery.data ?? null; + const commentStateQuery = useGithubCommentStateQuery(ref); + const openThreads = commentStateQuery.data?.openThreads ?? 0; + // Live activity wins over the PR's head ref: the agent may have moved to a + // new worktree since opening the PR, and activity updates immediately. + // While async sources are still resolving, fall back to what this chat + // last showed so a revisit renders the branch statically from the first + // frame instead of animating in from the placeholder. + const resolvedBranch = branch?.trim() || pr?.headRef?.trim() || null; + const currentBranch = + resolvedBranch ?? lastShownBranchByChat.get(chatId) ?? null; + React.useEffect(() => { + if (resolvedBranch) { + lastShownBranchByChat.set(chatId, resolvedBranch); + } + }, [chatId, resolvedBranch]); + // Latched per href for this mount: the effect below records the href as + // seen immediately, and un-latching would strip the class mid-animation. + const prEntranceDecisions = React.useRef(new Map()); + let isNewPrForChat = false; + if (preview) { + const latched = prEntranceDecisions.current.get(preview.href); + if (latched === undefined) { + isNewPrForChat = lastShownPrByChat.get(chatId) !== preview.href; + prEntranceDecisions.current.set(preview.href, isNewPrForChat); + } else { + isNewPrForChat = latched; + } + } + React.useEffect(() => { + if (preview?.href) { + lastShownPrByChat.set(chatId, preview.href); + } + }, [chatId, preview?.href]); + + const ciConditionActive = Boolean( + checks && checks.failed > 0 && checks.pending === 0, + ); + const sendCiNudge = React.useCallback(() => { + if (!onAutomationPrompt || !effectiveHref || !pr || !checks) { + return; + } + updateChatWorkAutomation(chatId, { + lastCiNudgeSha: pr.headSha, + lastCiNudgeAt: Date.now(), + }); + onAutomationPrompt( + `CI is failing on ${effectiveHref} (${checks.failed} of ${checks.total} checks). Investigate the failures and push fixes until the checks pass.`, + "ci", + ); + // The prompt itself is invisible in the timeline — acknowledge it. + toast.success(`Asked ${agentName} to fix the CI failures`); + }, [agentName, chatId, checks, effectiveHref, onAutomationPrompt, pr]); + const sendCommentNudge = React.useCallback(() => { + if (!onAutomationPrompt || !effectiveHref) { + return; + } + updateChatWorkAutomation(chatId, { + lastCommentNudgeCount: Math.max(openThreads, 1), + lastCommentNudgeAt: Date.now(), + }); + onAutomationPrompt( + `There are unanswered review comments on ${effectiveHref}. Address each comment and its replies, push any needed changes, reply to the threads, and resolve every conversation that has been addressed.`, + "comments", + ); + toast.success(`Asked ${agentName} to address the review comments`); + }, [agentName, chatId, effectiveHref, onAutomationPrompt, openThreads]); + + // Automation: prompt the agent on CI failure / newly-open review threads. + // Watermarks keep this to one nudge per failing sha and per rise in open + // threads (the thread watermark re-arms at zero); the cooldown path above + // covers conditions that persist with no agent working. + React.useEffect(() => { + if (!onAutomationPrompt || !effectiveHref || !pr) { + return; + } + if (automation.autoFixCi && ciConditionActive && checks) { + const isNewFailure = automation.lastCiNudgeSha !== pr.headSha; + const cooledDown = + !isTurnActive && + Date.now() - (automation.lastCiNudgeAt ?? 0) > RENUDGE_COOLDOWN_MS; + if (isNewFailure || cooledDown) { + sendCiNudge(); + } + } + const threadWatermark = automation.lastCommentNudgeCount ?? 0; + if (openThreads === 0 && threadWatermark !== 0) { + updateChatWorkAutomation(chatId, { lastCommentNudgeCount: 0 }); + } else if (automation.addressComments && openThreads > 0) { + const isNewComment = openThreads > threadWatermark; + const cooledDown = + !isTurnActive && + Date.now() - (automation.lastCommentNudgeAt ?? 0) > RENUDGE_COOLDOWN_MS; + if (isNewComment || cooledDown) { + sendCommentNudge(); + } + } + }, [ + automation, + chatId, + checks, + ciConditionActive, + effectiveHref, + isTurnActive, + onAutomationPrompt, + openThreads, + pr, + sendCiNudge, + sendCommentNudge, + ]); + + return ( + + ); +} + +/** Matrix jobs can repeat a check name; suffix repeats for stable keys. */ +function dedupeRunKeys(runs: GithubCheckSummary["runs"]) { + const seen = new Map(); + return runs.map((run) => { + const count = seen.get(run.name) ?? 0; + seen.set(run.name, count + 1); + return { key: count === 0 ? run.name : `${run.name} (${count})`, run }; + }); +} + +function CheckRunIcon({ state }: { state: "pending" | "success" | "failure" }) { + if (state === "pending") { + return ( + + ); + } + if (state === "failure") { + return ( + + ); + } + return ( + + ); +} + +function CiStatus({ checks }: { checks: GithubCheckSummary | null }) { + if (!checks || checks.total === 0) { + return ( + <> + + No checks + + ); + } + if (checks.pending > 0) { + return ( + <> + + + CI running ({checks.total - checks.pending}/{checks.total}) + + + ); + } + if (checks.failed > 0) { + return ( + <> + + + CI failing ({checks.failed}) + + + ); + } + return ( + <> + + + CI passing + + + ); +} diff --git a/desktop/src/features/chats/ui/ChatsScreen.tsx b/desktop/src/features/chats/ui/ChatsScreen.tsx new file mode 100644 index 0000000000..a71a5b3b9e --- /dev/null +++ b/desktop/src/features/chats/ui/ChatsScreen.tsx @@ -0,0 +1,686 @@ +// @ts-nocheck +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { GitPullRequest } from "lucide-react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useAppShell } from "@/app/AppShellContext"; +import { + useManagedAgentsQuery, + useStartManagedAgentMutation, +} from "@/features/agents/hooks"; +import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; +import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks"; +import { + useArchiveChatMutation, + useChatMetadataListQuery, + useChatMetadataQuery, + useChatsQuery, + useUpdateChatMetadataMutation, +} from "@/features/chats/hooks"; +import { buildChatProjects } from "@/features/chats/lib/chatProjects"; +import { isSharedChatMetadata } from "@/features/chats/lib/chatShared"; +import { ChatList } from "@/features/chats/ui/ChatList"; +import { + toggleStoredChatPin, + useStoredChatPins, +} from "@/features/chats/lib/chatPinStorage"; +import { latestUnambiguousPullRequestHref } from "@/features/chats/lib/chatWorkLinks"; +import { + mergeChatProjects, + upsertStoredChatProject, + useStoredChatProjects, +} from "@/features/chats/lib/chatProjectStorage"; +import { + buildChatCanvasContent, + buildProjectSetupContext, + uniqueMentionPubkeys, +} from "@/features/chats/lib/chatSetup"; +import { ChatDetail } from "@/features/chats/ui/ChatDetail"; +import { ChatHeaderActions } from "@/features/chats/ui/ChatHeaderActions"; +import { ChatRenameDialog } from "@/features/chats/ui/ChatRenameDialog"; +import { QuickStartChat } from "@/features/chats/ui/QuickStartChat"; +import { + useChannelMessagesQuery, + useChannelSubscription, + useSendMessageMutation, +} from "@/features/messages/hooks"; +import { ensureWelcomeGuideAgentInChannel } from "@/features/onboarding/welcomeGuide"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { addChannelMembers, getCanvas, setCanvas } from "@/shared/api/tauri"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { getMentionTagPubkey } from "@/shared/lib/resolveMentionNames"; +import { Button } from "@/shared/ui/button"; + +const WORK_PANEL_OPEN_KEY = "buzz:chats:work-panel-open:v1"; + +type ChatsScreenProps = { + initialProjectId?: string | null; + selectedChatId?: string | null; +}; + +async function backfillBlankChatCanvas(channelId: string, content: string) { + const existing = await getCanvas(channelId); + if (existing.content?.trim()) { + return false; + } + await setCanvas({ channelId, content }); + return true; +} + +async function addBotToChat(channelId: string, pubkey: string) { + const result = await addChannelMembers({ + channelId, + pubkeys: [pubkey], + role: "bot", + }); + const error = result.errors.find( + (entry) => normalizePubkey(entry.pubkey) === normalizePubkey(pubkey), + ); + if (error && !error.error.toLowerCase().includes("already")) { + throw new Error(error.error); + } +} + +export function ChatsScreen({ + initialProjectId, + selectedChatId = null, +}: ChatsScreenProps) { + const queryClient = useQueryClient(); + const { activeWorkspace } = useWorkspaces(); + const { goChat, goChats } = useAppNavigation(); + const { + getChannelReadAt, + markChannelRead, + readStateVersion, + unreadChannelCounts, + unreadChannelIds, + } = useAppShell(); + const identityQuery = useIdentityQuery(); + const chatsQuery = useChatsQuery(); + const chats = chatsQuery.data ?? []; + const metadataListQuery = useChatMetadataListQuery(); + const allMetadata = metadataListQuery.data ?? []; + const storedChatProjects = useStoredChatProjects(activeWorkspace?.id); + const templatesQuery = useChannelTemplatesQuery(); + const templates = templatesQuery.data ?? []; + const identityPubkey = identityQuery.data?.pubkey; + const ownedMetadata = React.useMemo( + () => + allMetadata.filter( + (metadata) => !isSharedChatMetadata(metadata, identityPubkey), + ), + [allMetadata, identityPubkey], + ); + const chatProjects = React.useMemo( + () => + mergeChatProjects(storedChatProjects, buildChatProjects(ownedMetadata)), + [ownedMetadata, storedChatProjects], + ); + const backfilledCanvasKeysRef = React.useRef(new Set()); + React.useEffect(() => { + if ( + chatsQuery.isLoading || + metadataListQuery.isLoading || + templatesQuery.isLoading + ) { + return; + } + + const chatsById = new Map(chats.map((chat) => [chat.id, chat])); + const templatesById = new Map( + templates.map((template) => [template.id, template]), + ); + + for (const metadata of ownedMetadata) { + const projectId = metadata.projectId?.trim(); + const projectName = metadata.projectName?.trim(); + if (!projectId || !projectName) { + continue; + } + + const chat = chatsById.get(metadata.channelId); + if (!chat) { + continue; + } + + const templateId = + metadata.projectTemplateId?.trim() || metadata.templateId?.trim() || ""; + const template = templateId ? templatesById.get(templateId) : null; + const project = { + id: projectId, + name: projectName, + path: metadata.projectPath?.trim() || null, + templateId: templateId || null, + updatedAt: metadata.updatedAt, + chatCount: 1, + }; + const leadingContent = buildProjectSetupContext({ + project, + templateName: template?.name ?? null, + }); + const content = buildChatCanvasContent({ + channelName: metadata.title?.trim() || chat.name, + leadingContent, + template, + }); + if (!content) { + continue; + } + + const backfillKey = [ + chat.id, + project.id, + project.path ?? "", + template?.id ?? "", + template?.updatedAt ?? "", + content.length, + ].join(":"); + if (backfilledCanvasKeysRef.current.has(backfillKey)) { + continue; + } + backfilledCanvasKeysRef.current.add(backfillKey); + + void backfillBlankChatCanvas(chat.id, content) + .then((didBackfill) => { + if (didBackfill) { + void queryClient.invalidateQueries({ + queryKey: ["channel-canvas", chat.id], + }); + } + }) + .catch((error) => { + console.warn("Failed to backfill chat canvas", chat.id, error); + }); + } + }, [ + chats, + chatsQuery.isLoading, + metadataListQuery.isLoading, + ownedMetadata, + queryClient, + templates, + templatesQuery.isLoading, + ]); + const metadataByChatId = React.useMemo( + () => + new Map(allMetadata.map((metadata) => [metadata.channelId, metadata])), + [allMetadata], + ); + const selectedChat = + selectedChatId !== null + ? (chats.find((chat) => chat.id === selectedChatId) ?? null) + : null; + + // Remember the last-viewed chat per workspace so returning to the Chats + // tab lands there instead of the new-chat screen. Explicit new-chat + // navigations carry a projectId in the route search (initialProjectId is + // then non-undefined) and are never redirected. + const lastChatStorageKey = activeWorkspace?.id + ? `buzz:chats:last-viewed:${activeWorkspace.id}` + : null; + React.useEffect(() => { + if (!lastChatStorageKey || !selectedChat) { + return; + } + try { + window.localStorage.setItem(lastChatStorageKey, selectedChat.id); + } catch { + // Storage unavailable — restore is best-effort. + } + }, [lastChatStorageKey, selectedChat]); + const attemptedChatRestoreRef = React.useRef(false); + React.useEffect(() => { + if (selectedChatId !== null) { + // Viewing a chat re-arms the restore for the next plain visit. + attemptedChatRestoreRef.current = false; + return; + } + if ( + initialProjectId !== undefined || + chatsQuery.isLoading || + attemptedChatRestoreRef.current + ) { + return; + } + attemptedChatRestoreRef.current = true; + let storedChatId: string | null = null; + try { + storedChatId = lastChatStorageKey + ? window.localStorage.getItem(lastChatStorageKey) + : null; + } catch { + storedChatId = null; + } + if (storedChatId && chats.some((chat) => chat.id === storedChatId)) { + void goChat(storedChatId, { replace: true }); + } + }, [ + chats, + chatsQuery.isLoading, + goChat, + initialProjectId, + lastChatStorageKey, + selectedChatId, + ]); + + const metadataQuery = useChatMetadataQuery(selectedChat?.id); + const metadata = metadataQuery.data ?? null; + const managedAgentsQuery = useManagedAgentsQuery(); + const metadataDefaultAgentPubkey = metadata?.defaultAgentPubkey ?? null; + const defaultAgent = React.useMemo(() => { + if (!metadataDefaultAgentPubkey) { + return null; + } + const normalizedDefaultAgentPubkey = normalizePubkey( + metadataDefaultAgentPubkey, + ); + return ( + (managedAgentsQuery.data ?? []).find( + (agent) => + normalizePubkey(agent.pubkey) === normalizedDefaultAgentPubkey, + ) ?? null + ); + }, [managedAgentsQuery.data, metadataDefaultAgentPubkey]); + + const messageQuery = useChannelMessagesQuery(selectedChat); + useChannelSubscription(selectedChat); + const messages = messageQuery.data ?? []; + const pubkeys = React.useMemo( + () => [ + ...new Set( + [ + identityQuery.data?.pubkey, + defaultAgent?.pubkey, + ...messages.map((message) => message.pubkey), + // Mentioned users too — chips need the mentioned profile's display + // name even when that user never posted in the chat. + ...messages.flatMap( + (message) => + message.tags?.flatMap((tag) => { + const pubkey = getMentionTagPubkey(tag); + return pubkey ? [pubkey] : []; + }) ?? [], + ), + ] + .filter((value): value is string => Boolean(value)) + .map((value) => value.toLowerCase()), + ), + ], + [defaultAgent?.pubkey, identityQuery.data?.pubkey, messages], + ); + const profilesQuery = useUsersBatchQuery(pubkeys, { + enabled: pubkeys.length > 0, + }); + const profiles = profilesQuery.data?.profiles; + const sendMessageMutation = useSendMessageMutation( + selectedChat, + identityQuery.data, + ); + const archiveChatMutation = useArchiveChatMutation(); + + // Latest PR link posted in the chat by ANY participant — drives the header + // toggle and the top-right work module. Author-scoping this proved too + // brittle (agents added to the chat aren't necessarily in the viewer's + // managed list), and a PR link dropped by a human is the chat's work too. + // Mixed status messages that mention several PRs are not reliable + // chat-to-PR ownership signals. + const agentPullRequestHref = React.useMemo(() => { + return latestUnambiguousPullRequestHref(messages); + }, [messages]); + // The drawer's open state is a single persisted preference — switching + // chats must NOT reset it or re-derive it (auto-open-on-PR re-animated + // the drawer on every switch). Purely remembered; default closed. + const [workPanelPreference, setWorkPanelPreference] = React.useState< + boolean | null + >(() => { + try { + const raw = window.localStorage.getItem(WORK_PANEL_OPEN_KEY); + return raw === null ? null : raw === "true"; + } catch { + return null; + } + }); + const handleWorkPanelPreference = React.useCallback((next: boolean) => { + setWorkPanelPreference(next); + try { + window.localStorage.setItem(WORK_PANEL_OPEN_KEY, String(next)); + } catch { + // Best-effort persistence. + } + }, []); + const isWorkPanelOpen = workPanelPreference ?? false; + const startManagedAgentMutation = useStartManagedAgentMutation(); + const [isEnsuringDefaultAgent, setIsEnsuringDefaultAgent] = + React.useState(false); + const handleArchiveChat = React.useCallback( + async (chatId: string) => { + try { + await archiveChatMutation.mutateAsync(chatId); + toast.success("Chat archived"); + if (selectedChatId === chatId) { + void goChats({ replace: true }); + } + } catch (error) { + toast.error("Could not archive chat", { + description: error instanceof Error ? error.message : undefined, + }); + } + }, + [archiveChatMutation, goChats, selectedChatId], + ); + + const updateMetadataMutation = useUpdateChatMetadataMutation(); + const pinnedChatIds = useStoredChatPins(activeWorkspace?.id); + const handleTogglePin = React.useCallback( + (chatId: string) => { + toggleStoredChatPin(activeWorkspace?.id, chatId); + }, + [activeWorkspace?.id], + ); + const [renamingChatId, setRenamingChatId] = React.useState( + null, + ); + const renamingChat = + renamingChatId !== null + ? (chats.find((chat) => chat.id === renamingChatId) ?? null) + : null; + const renamingMetadata = renamingChatId + ? (metadataByChatId.get(renamingChatId) ?? null) + : null; + const handleRenameChat = React.useCallback( + async (title: string) => { + if (!renamingChat) { + return; + } + const metadata = metadataByChatId.get(renamingChat.id) ?? null; + try { + await updateMetadataMutation.mutateAsync({ + channelId: renamingChat.id, + title, + defaultAgentPubkey: metadata?.defaultAgentPubkey ?? undefined, + templateId: metadata?.templateId ?? undefined, + projectId: metadata?.projectId ?? undefined, + projectName: metadata?.projectName ?? undefined, + projectPath: metadata?.projectPath ?? undefined, + projectTemplateId: metadata?.projectTemplateId ?? undefined, + source: metadata?.sourceChannelId + ? { + channelId: metadata.sourceChannelId, + eventId: metadata.sourceEventId ?? undefined, + threadRootId: metadata.sourceThreadRootId ?? undefined, + } + : undefined, + }); + setRenamingChatId(null); + } catch (error) { + toast.error("Could not rename chat", { + description: error instanceof Error ? error.message : undefined, + }); + } + }, + [metadataByChatId, renamingChat, updateMetadataMutation], + ); + const ensuredChatIdsRef = React.useRef(new Set()); + React.useEffect(() => { + if (!selectedChat || metadataQuery.isLoading) { + return; + } + if (metadata?.defaultAgentPubkey) { + return; + } + if (ensuredChatIdsRef.current.has(selectedChat.id)) { + return; + } + ensuredChatIdsRef.current.add(selectedChat.id); + void ensureWelcomeGuideAgentInChannel( + selectedChat.id, + activeWorkspace?.relayUrl, + ) + .then((agent) => + updateMetadataMutation.mutateAsync({ + channelId: selectedChat.id, + defaultAgentPubkey: agent.pubkey, + title: metadata?.title ?? selectedChat.name, + templateId: metadata?.templateId ?? undefined, + projectId: metadata?.projectId ?? undefined, + projectName: metadata?.projectName ?? undefined, + projectPath: metadata?.projectPath ?? undefined, + projectTemplateId: metadata?.projectTemplateId ?? undefined, + source: metadata?.sourceChannelId + ? { + channelId: metadata.sourceChannelId, + eventId: metadata.sourceEventId ?? undefined, + threadRootId: metadata.sourceThreadRootId ?? undefined, + } + : undefined, + }), + ) + .catch((error) => { + console.error("Failed to ensure Fizz for chat", selectedChat.id, error); + }); + }, [ + activeWorkspace?.relayUrl, + metadata, + metadataQuery.isLoading, + selectedChat, + updateMetadataMutation, + ]); + + React.useEffect(() => { + if (!selectedChat || messages.length === 0) { + return; + } + const lastMessage = messages[messages.length - 1]; + if (!lastMessage) { + return; + } + markChannelRead( + selectedChat.id, + new Date(lastMessage.created_at * 1_000).toISOString(), + ); + }, [markChannelRead, messages, selectedChat]); + + const handleSend = React.useCallback( + async ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => { + const defaultAgentPubkey = + metadata?.defaultAgentPubkey ?? defaultAgent?.pubkey ?? null; + await sendMessageMutation.mutateAsync({ + content, + mentionPubkeys: uniqueMentionPubkeys( + identityQuery.data?.pubkey, + mentionPubkeys, + defaultAgentPubkey, + ), + mediaTags, + }); + }, + [ + defaultAgent?.pubkey, + identityQuery.data?.pubkey, + metadata?.defaultAgentPubkey, + sendMessageMutation, + ], + ); + + const handleActivateAgent = React.useCallback(async () => { + if (!selectedChat) { + return; + } + + setIsEnsuringDefaultAgent(true); + try { + if (defaultAgent) { + await addBotToChat(selectedChat.id, defaultAgent.pubkey); + if (!isManagedAgentActive(defaultAgent)) { + // No success toast: the process starting is not the same as the + // agent responding — the activation card holds its loading state + // until the agent's turn actually begins. + await startManagedAgentMutation.mutateAsync(defaultAgent.pubkey); + } else { + await managedAgentsQuery.refetch(); + toast.success(`${defaultAgent.name} activated`); + } + return; + } + + const agent = await ensureWelcomeGuideAgentInChannel( + selectedChat.id, + activeWorkspace?.relayUrl, + ); + await updateMetadataMutation.mutateAsync({ + channelId: selectedChat.id, + defaultAgentPubkey: agent.pubkey, + title: metadata?.title ?? selectedChat.name, + templateId: metadata?.templateId ?? undefined, + projectId: metadata?.projectId ?? undefined, + projectName: metadata?.projectName ?? undefined, + projectPath: metadata?.projectPath ?? undefined, + projectTemplateId: metadata?.projectTemplateId ?? undefined, + source: metadata?.sourceChannelId + ? { + channelId: metadata.sourceChannelId, + eventId: metadata.sourceEventId ?? undefined, + threadRootId: metadata.sourceThreadRootId ?? undefined, + } + : undefined, + }); + await managedAgentsQuery.refetch(); + toast.success(`${agent.name || "Fizz"} activated`); + } catch (error) { + console.error("Failed to activate chat agent", error); + toast.error("Could not activate agent", { + description: error instanceof Error ? error.message : undefined, + }); + } finally { + setIsEnsuringDefaultAgent(false); + } + }, [ + activeWorkspace?.relayUrl, + defaultAgent, + managedAgentsQuery, + metadata, + selectedChat, + startManagedAgentMutation, + updateMetadataMutation, + ]); + + return ( +
+ + +
+ {selectedChat ? ( + + upsertStoredChatProject(activeWorkspace?.id, project) + } + onSend={handleSend} + profiles={profiles} + projects={chatProjects} + showWorkPanel={isWorkPanelOpen} + workPanelHref={agentPullRequestHref} + shareAction={ + handleWorkPanelPreference(!isWorkPanelOpen)} + size="icon" + type="button" + variant="ghost" + > + + + } + /> + } + templates={templates} + /> + ) : ( + + upsertStoredChatProject(activeWorkspace?.id, project) + } + onCreated={(chat) => void goChat(chat.id, { replace: true })} + /> + )} +
+ { + if (!open) { + setRenamingChatId(null); + } + }} + onRename={(title) => void handleRenameChat(title)} + open={renamingChat !== null} + /> +
+ ); +} diff --git a/desktop/src/features/chats/ui/QuickStartChat.tsx b/desktop/src/features/chats/ui/QuickStartChat.tsx new file mode 100644 index 0000000000..622583378e --- /dev/null +++ b/desktop/src/features/chats/ui/QuickStartChat.tsx @@ -0,0 +1,696 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Check, + ChevronDown, + Notebook, + NotebookPen, + NotepadTextDashed, + Search, +} from "lucide-react"; +import { toast } from "sonner"; + +import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks"; +import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"; +import { ChatHeader } from "@/features/chat/ui/ChatHeader"; +import { + managedAgentsQueryKey, + useAvailableAcpRuntimes, + useManagedAgentsQuery, + usePersonasQuery, + useTeamsQuery, +} from "@/features/agents/hooks"; +import { + attachManagedAgentToChannel, + createChannelManagedAgents, + type CreateChannelManagedAgentInput, +} from "@/features/agents/channelAgents"; +import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; +import { + getUsableTeams, + resolveTeamPersonas, +} from "@/features/agents/lib/teamPersonas"; +import { useLastRuntime } from "@/features/agents/lib/useLastRuntime"; +import { + type ChatAgentPreset, + type ChatInvitee, + ChatStartPresets, + ProjectPresetCard, +} from "@/features/chats/ui/ChatStartPresets"; +import { + useCreateChatMutation, + useSendChatContextMessageMutation, + useUpdateChatMetadataMutation, +} from "@/features/chats/hooks"; +import { + buildProjectSetupContext, + deriveChatTitle, + type ChatProject, + NO_PROJECT_SELECTION_ID, + uniqueMentionPubkeys, +} from "@/features/chats/lib/chatSetup"; +import { ChatProjectDialog } from "@/features/chats/ui/ChatProjectDialog"; +import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; +import { MessageComposer } from "@/features/messages/ui/MessageComposer"; +import { + ensureWelcomeGuideAgentInChannel, + WELCOME_GUIDE_AGENT_NAME, + WELCOME_GUIDE_PERSONA_ID, +} from "@/features/onboarding/welcomeGuide"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { addChannelMembers, sendChannelMessage } from "@/shared/api/tauri"; +import type { + AgentTeam, + Channel, + ChannelTemplate, + ManagedAgent, +} from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; + +type QuickStartChatProps = { + initialProjectId?: string | null; + onCreated: (chat: Channel) => void; + onProjectCreated?: (project: ChatProject) => void; + projects: ChatProject[]; + relayUrl?: string | null; +}; + +function errorMessage(error: unknown) { + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + if (typeof error === "string" && error.trim()) { + return error.trim(); + } + return "Unknown error"; +} + +function nonAgentMentionPubkeys({ + defaultAgentPubkey, + identityPubkey, + managedAgentPubkeys, + mentionPubkeys, +}: { + defaultAgentPubkey?: string | null; + identityPubkey?: string | null; + managedAgentPubkeys: string[]; + mentionPubkeys: string[]; +}) { + const blockedPubkeys = new Set( + [identityPubkey, defaultAgentPubkey, ...managedAgentPubkeys] + .map((pubkey) => normalizePubkey(pubkey ?? "")) + .filter(Boolean), + ); + return [ + ...new Set(mentionPubkeys.map((pubkey) => normalizePubkey(pubkey))), + ].filter((pubkey) => pubkey && !blockedPubkeys.has(pubkey)); +} + +export function QuickStartChat({ + initialProjectId, + onCreated, + onProjectCreated, + projects, + relayUrl, +}: QuickStartChatProps) { + const [selectedProjectId, setSelectedProjectId] = React.useState< + string | null + >(() => initialProjectSelection(initialProjectId, projects)); + const [isCreating, setIsCreating] = React.useState(false); + const [agentPreset, setAgentPreset] = React.useState({ + kind: "default", + }); + const [invited, setInvited] = React.useState([]); + const queryClient = useQueryClient(); + const identityQuery = useIdentityQuery(); + const createChatMutation = useCreateChatMutation(); + const updateMetadataMutation = useUpdateChatMetadataMutation(); + const sendContextMutation = useSendChatContextMessageMutation(); + const templatesQuery = useChannelTemplatesQuery(); + const managedAgentsQuery = useManagedAgentsQuery(); + const { applyAgents, applyCanvas } = useApplyTemplate(); + const personasQuery = usePersonasQuery(); + const teamsQuery = useTeamsQuery(); + const acpRuntimesQuery = useAvailableAcpRuntimes(); + const { lastRuntimeId } = useLastRuntime(); + const usableTeams = React.useMemo( + () => getUsableTeams(teamsQuery.data ?? [], personasQuery.data ?? []), + [personasQuery.data, teamsQuery.data], + ); + // The welcome guide already has the "Default agent" row — listing its + // managed instance again reads as a duplicate Fizz. + const pickableAgents = React.useMemo( + () => + (managedAgentsQuery.data ?? []).filter( + (agent) => + agent.personaId !== WELCOME_GUIDE_PERSONA_ID && + agent.name !== WELCOME_GUIDE_AGENT_NAME, + ), + [managedAgentsQuery.data], + ); + const templates = templatesQuery.data ?? []; + const allProjects = projects; + const selectedProject = + allProjects.find((project) => project.id === selectedProjectId) ?? null; + const selectedTemplate = + templates.find((template) => template.id === selectedProject?.templateId) ?? + null; + + React.useEffect(() => { + if (initialProjectId === undefined) { + return; + } + const nextSelection = initialProjectSelection( + initialProjectId, + allProjects, + ); + setSelectedProjectId(nextSelection); + }, [allProjects, initialProjectId]); + + React.useEffect(() => { + if ( + selectedProjectId !== null || + initialProjectId !== undefined || + allProjects.length === 0 + ) { + return; + } + setSelectedProjectId(allProjects[0].id); + }, [allProjects, initialProjectId, selectedProjectId]); + + const handleCreateProject = React.useCallback( + (project: ChatProject) => { + onProjectCreated?.(project); + setSelectedProjectId(project.id); + }, + [onProjectCreated], + ); + + // Create the team's persona agents in the new chat (mirrors the template + // agent flow) and return the first as the chat's default agent. + const createTeamAgents = React.useCallback( + async (team: AgentTeam, channelId: string): Promise => { + const runtimes = acpRuntimesQuery.data ?? []; + const defaultProvider = + runtimes.find((runtime) => runtime.id === lastRuntimeId) ?? + runtimes[0] ?? + null; + if (!defaultProvider) { + throw new Error("No agent runtimes available for the team"); + } + const { resolvedPersonas } = resolveTeamPersonas( + team, + personasQuery.data ?? [], + ); + const inputs: CreateChannelManagedAgentInput[] = resolvedPersonas.map( + (persona) => ({ + runtime: + resolvePersonaRuntime(persona.runtime, runtimes, defaultProvider) + .runtime ?? defaultProvider, + name: persona.displayName, + personaId: persona.id, + systemPrompt: persona.systemPrompt, + avatarUrl: persona.avatarUrl ?? undefined, + model: persona.model ?? undefined, + role: "bot", + ensureRunning: true, + }), + ); + if (inputs.length === 0) { + throw new Error("The team has no usable agents"); + } + const result = await createChannelManagedAgents(channelId, inputs); + const first = result.successes[0]?.agent; + if (!first) { + throw new Error( + result.failures[0]?.error ?? "Could not create the team's agents", + ); + } + if (result.failures.length > 0) { + toast.warning( + result.failures.length === 1 + ? "1 team agent could not be created" + : `${result.failures.length} team agents could not be created`, + ); + } + return first; + }, + [acpRuntimesQuery.data, lastRuntimeId, personasQuery.data], + ); + + const handleCreate = React.useCallback( + async ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => { + const trimmed = content.trim(); + if (!trimmed && !mediaTags?.length) { + return; + } + if (isCreating) { + return; + } + + setIsCreating(true); + try { + const title = deriveChatTitle(trimmed); + const templateId = selectedProject?.templateId ?? undefined; + const chat = await createChatMutation.mutateAsync({ + title, + templateId, + projectId: selectedProject?.id, + projectName: selectedProject?.name, + projectPath: selectedProject?.path ?? undefined, + projectTemplateId: selectedProject?.templateId ?? undefined, + }); + + const projectCanvasContext = buildProjectSetupContext({ + project: selectedProject, + templateName: selectedTemplate?.name, + }); + await applyCanvas(templateId, chat.id, title, projectCanvasContext); + void applyAgents(templateId, chat.id); + + // The preset picked on the start screen decides which agent(s) the + // chat opens with; the welcome guide remains the default. + let agent: ManagedAgent; + if (agentPreset.kind === "agent") { + const attached = await attachManagedAgentToChannel(chat.id, { + agent: agentPreset.agent, + ensureRunning: true, + role: "bot", + }); + agent = attached.agent; + } else if (agentPreset.kind === "team") { + agent = await createTeamAgents(agentPreset.team, chat.id); + } else { + agent = await ensureWelcomeGuideAgentInChannel(chat.id, relayUrl); + } + // The agent may have just been created/started outside the mutation + // hooks — refresh the managed-agents cache so the new chat resolves + // its default agent immediately (agent replies render as agent rows, + // not member bubbles). + await queryClient.invalidateQueries({ + queryKey: managedAgentsQueryKey, + }); + const setupContext = buildProjectSetupContext({ + agent, + project: selectedProject, + templateName: selectedTemplate?.name, + }); + + await updateMetadataMutation.mutateAsync({ + channelId: chat.id, + title, + defaultAgentPubkey: agent.pubkey, + templateId, + projectId: selectedProject?.id, + projectName: selectedProject?.name, + projectPath: selectedProject?.path ?? undefined, + projectTemplateId: selectedProject?.templateId ?? undefined, + }); + + if (setupContext) { + await sendContextMutation.mutateAsync({ + channelId: chat.id, + content: setupContext, + }); + } + + const memberMentionPubkeys = [ + ...new Set([ + ...nonAgentMentionPubkeys({ + defaultAgentPubkey: agent.pubkey, + identityPubkey: identityQuery.data?.pubkey, + managedAgentPubkeys: + managedAgentsQuery.data?.map( + (managedAgent) => managedAgent.pubkey, + ) ?? [], + mentionPubkeys, + }), + // People picked in the invite preset card. + ...invited.map((person) => normalizePubkey(person.pubkey)), + ]), + ]; + if (memberMentionPubkeys.length > 0) { + const result = await addChannelMembers({ + channelId: chat.id, + pubkeys: memberMentionPubkeys, + role: "member", + }); + if (result.errors.length > 0) { + throw new Error( + `Could not share chat: ${result.errors + .map((memberError) => memberError.error) + .join("; ")}`, + ); + } + } + + const { + mediaTags: imetaTags, + emojiTags, + mentionTags, + } = splitOutgoingTags(mediaTags); + await sendChannelMessage( + chat.id, + content, + null, + imetaTags, + uniqueMentionPubkeys( + identityQuery.data?.pubkey, + mentionPubkeys, + agent.pubkey, + ), + undefined, + emojiTags, + mentionTags, + ); + if (selectedProject) { + onProjectCreated?.({ + ...selectedProject, + chatCount: selectedProject.chatCount + 1, + updatedAt: Math.floor(Date.now() / 1_000), + }); + } + onCreated(chat); + } catch (error) { + console.error("Failed to create chat", error); + toast.error("Could not start chat", { + description: errorMessage(error), + }); + throw error; + } finally { + setIsCreating(false); + } + }, + [ + agentPreset, + applyAgents, + applyCanvas, + createChatMutation, + createTeamAgents, + invited, + identityQuery.data?.pubkey, + isCreating, + managedAgentsQuery.data, + queryClient, + onCreated, + onProjectCreated, + relayUrl, + selectedProject, + selectedTemplate?.name, + sendContextMutation, + updateMetadataMutation, + ], + ); + + const projectPicker = ( + + ); + + return ( +
+ + +
+
+
+

+ Start a chat +

+

+ Describe a task or ask a question. +

+
+ + } + /> + } + teams={usableTeams} + /> +
+
+ +
+ +
+
+ ); +} + +type SetupPillProps = React.ComponentPropsWithoutRef & { + testId?: string; +}; + +const SetupPill = React.forwardRef( + function SetupPill({ children, className, testId, ...props }, ref) { + return ( + + ); + }, +); + +export function ProjectPicker({ + isNoProjectSelected, + onCreateProject, + onSelectProject, + projects, + selectedProject, + templates, + trigger, +}: { + isNoProjectSelected: boolean; + onCreateProject: (project: ChatProject) => void; + onSelectProject: (projectId: string | null) => void; + projects: ChatProject[]; + selectedProject: ChatProject | null; + templates: ChannelTemplate[]; + /** Custom popover trigger; defaults to the composer's setup pill. */ + trigger?: React.ReactNode; +}) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const [isCreateOpen, setIsCreateOpen] = React.useState(false); + const filteredProjects = React.useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return projects; + } + return projects.filter((project) => + [project.name, project.path ?? ""] + .join(" ") + .toLowerCase() + .includes(normalizedQuery), + ); + }, [projects, query]); + + return ( + <> + + + {trigger ?? ( + + + + {selectedProject?.name || + (isNoProjectSelected ? "No project" : "Project")} + + + )} + + +
+ + setQuery(event.target.value)} + placeholder="Search projects" + value={query} + /> +
+
+ {filteredProjects.length > 0 ? ( + filteredProjects.map((project) => ( + { + onSelectProject(project.id); + setOpen(false); + }} + project={project} + /> + )) + ) : ( +
+ {projects.length === 0 + ? "No projects yet" + : "No projects found"} +
+ )} +
+
+ } + label="New project" + onSelect={() => { + setIsCreateOpen(true); + setOpen(false); + }} + /> + } + label="No project" + onSelect={() => { + onSelectProject(NO_PROJECT_SELECTION_ID); + setOpen(false); + }} + /> + + + { + onCreateProject(project); + void onSelectProject(project.id); + }} + onOpenChange={setIsCreateOpen} + open={isCreateOpen} + templates={templates} + /> + + ); +} + +function ProjectPickerRow({ + checked, + onSelect, + project, +}: { + checked: boolean; + onSelect: () => void; + project: ChatProject; +}) { + return ( + + ); +} + +function ProjectModeRow({ + checked, + icon, + label, + onSelect, +}: { + checked?: boolean; + icon: React.ReactNode; + label: string; + onSelect: () => void; +}) { + return ( + + ); +} + +function initialProjectSelection( + initialProjectId: string | null | undefined, + projects: ChatProject[], +) { + if (initialProjectId === null) { + return NO_PROJECT_SELECTION_ID; + } + if (initialProjectId) { + return initialProjectId; + } + return projects[0]?.id ?? null; +} diff --git a/desktop/src/features/chats/ui/chatActivityIcons.tsx b/desktop/src/features/chats/ui/chatActivityIcons.tsx new file mode 100644 index 0000000000..55061f75b8 --- /dev/null +++ b/desktop/src/features/chats/ui/chatActivityIcons.tsx @@ -0,0 +1,97 @@ +import { + AlertTriangle, + CheckCircle2, + MessageCircle, + Pencil, + Search, + SquareTerminal, +} from "lucide-react"; + +import type { TranscriptSameKindSummary } from "@/features/agents/ui/agentSessionTranscriptGrouping"; +import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; +import { buildCompactToolSummary } from "@/features/agents/ui/agentSessionToolSummary"; +import { getToolString } from "@/features/agents/ui/agentSessionUtils"; + +type ToolItem = Extract; +type CompactToolSummary = ReturnType; + +export function activityItemIcon(item: TranscriptItem) { + if (item.type === "tool") { + return toolIcon(item, buildCompactToolSummary(item)); + } + return null; +} + +export function toolIcon(item: ToolItem, summary: CompactToolSummary) { + if (item.isError || item.status === "failed") { + return ; + } + return toolCategoryIcon(item, summary); +} + +export function toolCategoryIcon(item: ToolItem, summary: CompactToolSummary) { + const kind = summary.kind; + if (kind === "file-edit") { + return ; + } + if (isSearchTool(item)) { + return ; + } + if (isShellCommandTool(item, summary)) { + return ; + } + if (kind === "message") { + return ; + } + return ; +} + +export function summaryIcon(summary: TranscriptSameKindSummary) { + if (summary.renderClass === "file-edit") { + return ; + } + if ( + summary.items.some((item) => item.type === "tool" && isSearchTool(item)) + ) { + return ; + } + if ( + summary.renderClass === "shell" || + summary.items.some( + (item) => + item.type === "tool" && + isShellCommandTool(item, buildCompactToolSummary(item)), + ) + ) { + return ; + } + return ; +} + +export function isSearchTool(item: ToolItem) { + return buildCompactToolSummary(item).action?.verb === "Searched"; +} + +export function isShellOriginTool(summary: CompactToolSummary) { + return ( + summary.kind === "shell" || + summary.descriptor.source === "shell" || + summary.descriptor.groupKey?.startsWith("shell:") === true + ); +} + +export function isShellCommandTool( + item: ToolItem, + summary: CompactToolSummary, +) { + return ( + isShellOriginTool(summary) || getToolString(item.args, ["command"]) !== null + ); +} + +export function getShellCommand(item: ToolItem, summary: CompactToolSummary) { + if (!isShellCommandTool(item, summary)) { + return null; + } + return getToolString(item.args, ["command"]) ?? summary.preview ?? "command"; +} diff --git a/desktop/src/features/chats/ui/chatActivityText.test.mjs b/desktop/src/features/chats/ui/chatActivityText.test.mjs new file mode 100644 index 0000000000..47ebb8313b --- /dev/null +++ b/desktop/src/features/chats/ui/chatActivityText.test.mjs @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + cleanAssistantMessageText, + isHumanFacingAssistantText, + toolLabel, +} from "./chatActivityText.ts"; + +const baseTimestamp = "2026-07-02T09:30:00.000Z"; + +function makeTool(overrides = {}) { + return { + id: "tool:1", + type: "tool", + title: "Tool call", + toolName: "shell", + buzzToolName: null, + status: "completed", + args: {}, + result: "", + isError: false, + timestamp: baseTimestamp, + startedAt: baseTimestamp, + completedAt: "2026-07-02T09:30:01.000Z", + ...overrides, + }; +} + +test("cleanAssistantMessageText strips chat-mode emojis", () => { + assert.equal( + cleanAssistantMessageText("Let's do it! 🚀 What are we trying out?"), + "Let's do it! What are we trying out?", + ); +}); + +test("cleanAssistantMessageText preserves markdown line breaks", () => { + assert.equal( + cleanAssistantMessageText( + "Here's the full system:\n\n---\n\n## Desktop\n\n| Utility | Value |\n| --- | --- |\n| rounded-lg | 10px |", + ), + "Here's the full system:\n\n---\n\n## Desktop\n\n| Utility | Value |\n| --- | --- |\n| rounded-lg | 10px |", + ); +}); + +test("isHumanFacingAssistantText hides agent tool summary replies", () => { + assert.equal( + isHumanFacingAssistantText( + "Replied to Kenny's message, ready to help with whatever he wants to try out.", + ), + false, + ); + assert.equal( + isHumanFacingAssistantText( + "Replied to Kenny confirming I'm online and ready to help.", + ), + false, + ); + assert.equal( + isHumanFacingAssistantText( + "Now I have all the button details across all three platforms. Let me send the reply.", + ), + false, + ); + assert.equal( + isHumanFacingAssistantText( + "Now I have a comprehensive picture. Let me also check the Tailwind config for any custom radius values:", + ), + false, + ); + assert.equal( + isHumanFacingAssistantText( + "Now let me create a new worktree from the latest main in the REPOS directory for this workspace:", + ), + false, + ); + assert.equal( + isHumanFacingAssistantText( + "Now let me find the agent popover menu component in the desktop app:", + ), + false, + ); + assert.equal( + isHumanFacingAssistantText( + "Let me look at the ProfilePopover.tsx and UserProfilePopover.tsx; these are likely the agent popover components:", + ), + false, + ); + assert.equal( + isHumanFacingAssistantText( + "That's the recent change. Let me look at the full diff for that commit to understand what was changed:", + ), + false, + ); + assert.equal( + isHumanFacingAssistantText( + "Now I have everything. Let me send the comprehensive corner radius breakdown.", + ), + false, + ); + assert.equal( + isHumanFacingAssistantText( + "I sent a detailed breakdown of the Buzz app's button system across all three platforms.", + ), + false, + ); + assert.equal( + isHumanFacingAssistantText( + "Done! I sent a comprehensive breakdown of the corner radius system covering: Desktop and mobile.", + ), + false, + ); + assert.equal( + isHumanFacingAssistantText( + "The message 'okay, you can do it again' is top-level, not in a thread, so it's a bit ambitious.", + ), + false, + ); + assert.equal( + isHumanFacingAssistantText("I asked Kenny for clarification."), + false, + ); + assert.equal( + isHumanFacingAssistantText("They could mean repeat the previous request."), + false, + ); + assert.equal( + isHumanFacingAssistantText("I should ask Kenny for clarification."), + false, + ); + assert.equal( + isHumanFacingAssistantText("Still here. What should we try?"), + true, + ); + assert.equal( + isHumanFacingAssistantText( + "Here's the full breakdown of buttons across all Buzz platforms: Desktop, web, and mobile each use slightly different button primitives.", + ), + true, + ); +}); + +test("toolLabel keeps command details only while shell commands run", () => { + assert.equal( + toolLabel( + makeTool({ + status: "executing", + args: { command: 'find . -name "*.md"' }, + }), + ), + 'Running find . -name "*.md"', + ); + assert.equal( + toolLabel(makeTool({ args: { command: 'find . -name "*.md"' } })), + "Ran command", + ); +}); + +test("toolLabel keeps search details only while searches run", () => { + assert.equal( + toolLabel( + makeTool({ + toolName: "search", + buzzToolName: "search", + status: "executing", + args: { query: "chat mode" }, + }), + ), + "Searching chat mode", + ); + assert.equal( + toolLabel( + makeTool({ + toolName: "search", + buzzToolName: "search", + args: { query: "chat mode" }, + }), + ), + "Searched", + ); +}); diff --git a/desktop/src/features/chats/ui/chatActivityText.ts b/desktop/src/features/chats/ui/chatActivityText.ts new file mode 100644 index 0000000000..6f1581a331 --- /dev/null +++ b/desktop/src/features/chats/ui/chatActivityText.ts @@ -0,0 +1,168 @@ +import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; +import { buildCompactToolSummary } from "@/features/agents/ui/agentSessionToolSummary"; + +export type ActivityMarkerTone = + | "default" + | "muted" + | "success" + | "warning" + | "danger"; + +export function cleanAssistantMessageText(text: string) { + return stripChatModeEmoji(text); +} + +export function normalizeAssistantMessageTextForMatching(text: string) { + return cleanAssistantMessageText(text).replace(/\s+/g, " ").trim(); +} + +export function isHumanFacingAssistantText(text: string) { + const normalized = normalizeAssistantMessageTextForMatching(text); + return normalized.length > 0 && !isAgentInternalNarration(normalized); +} + +export function cleanChatMessageText( + item: Extract, +) { + const text = item.text.trim(); + if (item.role !== "assistant") { + return text; + } + return cleanAssistantMessageText(text); +} + +export function isHumanFacingAssistantMessage( + item: Extract, +) { + const text = normalizeAssistantMessageTextForMatching( + cleanChatMessageText(item), + ); + return text.length > 0 && !isAgentInternalNarration(text); +} + +export function toolLabel(item: Extract) { + const summary = buildCompactToolSummary(item); + const preview = summary.preview?.trim(); + const isRunning = item.status === "executing" || item.status === "pending"; + if (summary.kind === "shell") { + if (isRunning) { + return ["Running", preview].filter(Boolean).join(" "); + } + if (item.isError || item.status === "failed") { + return "Command failed"; + } + return "Ran command"; + } + if (summary.action?.verb === "Searched") { + return isRunning + ? ["Searching", preview].filter(Boolean).join(" ") + : "Searched"; + } + if (summary.kind === "message") { + return "Sent message"; + } + if (summary.action) { + return [summary.action.verb, summary.action.object ?? summary.preview] + .filter(Boolean) + .join(" "); + } + if (summary.fileEditSummary) { + return `Edited ${summary.fileEditSummary.filename}`; + } + return [summary.label, summary.preview].filter(Boolean).join(" · "); +} + +export function completedWorkLabel(items: TranscriptItem[]) { + const duration = workDuration(items); + return duration ? `Thought for ${duration}` : "Thought"; +} + +export function activityItemLabel(item: TranscriptItem) { + if (item.type === "tool") { + return toolLabel(item); + } + if (item.type === "metadata") { + return `Captured ${item.title.toLowerCase()}`; + } + if (item.type === "plan") { + return item.isUpdate + ? item.text + ? `Updated plan · ${item.text}` + : "Updated plan" + : "Updated plan"; + } + if (item.type === "thought") { + return item.title || "Thinking"; + } + if (item.type === "lifecycle") { + return item.title; + } + if (item.type === "message" && item.role === "user") { + return "User prompt"; + } + return ""; +} + +export function activityItemTone(item: TranscriptItem): ActivityMarkerTone { + if (item.type === "tool") { + if (item.isError || item.status === "failed") return "danger"; + if (item.status === "executing" || item.status === "pending") { + return "warning"; + } + return "default"; + } + if (item.type === "lifecycle" && item.renderClass === "error") { + return "danger"; + } + return "muted"; +} + +function stripChatModeEmoji(text: string) { + return text + .trim() + .replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu, "") + .replace(/[ \t]{2,}/g, " ") + .replace(/[ \t]+\n/g, "\n") + .trim(); +} + +function isAgentInternalNarration(text: string) { + return AGENT_INTERNAL_NARRATION_PATTERNS.some((pattern) => + pattern.test(text), + ); +} + +const AGENT_INTERNAL_NARRATION_PATTERNS = [ + /^now\s+let\s+me\b/i, + /^let\s+me\s+(?:also\s+)?(?:check|create|find|grab|inspect|look|open|pull|read|review|run|search|see|verify)\b/i, + /^(?:replied|replying|responded|sent|posted)\s+to\s+.+(?:\b(?:message|thread|channel)\b|[, ]\s*(?:confirming|saying|telling|noting|letting|ready|with|that)\b)/i, + /^(?:asked|told|replied|responded)\s+(?:kenny|kenneth|the\s+user|him|her|them)\b/i, + /^(?:i\s+)?(?:asked|told|replied\s+to|responded\s+to)\s+(?:kenny|kenneth|the\s+user|him|her|them)\b/i, + /^(?:now\s+)?(?:i|we)\s+(?:can\s+see|have|found|got|gathered|collected|see)\b.+\blet\s+me\s+(?:(?:also\s+)?(?:check|create|find|inspect|look|verify|review|search|run)|(?:reply|send|post)\b)/i, + /^(?:done[!.]?\s+)?(?:i|we)(?:'ve|\s+have|\s+just)?\s+(?:sent|posted)\b.+\b(?:breakdown|summary|details?|reply|response|answer)\b/i, + /^(?:i|we)(?:'ll|\s+will|\s+am\s+going\s+to|\s+are\s+going\s+to)\s+(?:send|post)\b/i, + /^let\s+me\s+(?:reply|send|post)\b/i, + /^that(?:'s|\s+is)\b.+\blet\s+me\b/i, + /^(?:the|this|that)\s+(?:message|request|prompt)\b.+\b(?:top-level|thread|channel|reply|ambiguous|ambitious|could\s+mean|likely\s+means|seems\s+to\s+mean|clarification)\b/i, + /^(?:kenny|kenneth|the\s+user|he|she|they)\s+(?:asked|wants|means|could\s+mean|might\s+mean|is\s+asking|is\s+referring)\b/i, + /^(?:i|we)\s+(?:need\s+to|should|can|will)\s+(?:ask|request)\s+(?:kenny|kenneth|the\s+user|him|her|them)\s+for\s+clarification\b/i, +] satisfies RegExp[]; + +function workDuration(items: TranscriptItem[]) { + const times = items + .map((item) => Date.parse(item.timestamp)) + .filter((value) => Number.isFinite(value)); + if (times.length < 2) { + return null; + } + const seconds = Math.max( + 1, + Math.round((Math.max(...times) - Math.min(...times)) / 1000), + ); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`; +} diff --git a/desktop/src/features/chats/ui/messageEntrance.ts b/desktop/src/features/chats/ui/messageEntrance.ts new file mode 100644 index 0000000000..831e7a2d3d --- /dev/null +++ b/desktop/src/features/chats/ui/messageEntrance.ts @@ -0,0 +1,30 @@ +// Rows younger than this get the entrance animation; anything older (opening +// a chat, reconnect backfill, history pagination) renders statically so +// existing conversation content doesn't cascade in. +const ENTRANCE_RECENCY_WINDOW_MS = 10_000; + +export const MESSAGE_ENTRANCE_CLASS = "buzz-message-entrance"; + +export function isEntranceRecent(timestampMs: number, nowMs = Date.now()) { + return ( + Number.isFinite(timestampMs) && + nowMs - timestampMs < ENTRANCE_RECENCY_WINDOW_MS + ); +} + +/** Entrance class for an ISO-8601 timestamp (transcript items). */ +export function entranceClassForIso(timestamp?: string | null) { + if (!timestamp) { + return undefined; + } + return isEntranceRecent(Date.parse(timestamp)) + ? MESSAGE_ENTRANCE_CLASS + : undefined; +} + +/** Entrance class for a Nostr `created_at` (seconds since epoch). */ +export function entranceClassForCreatedAt(createdAtSeconds: number) { + return isEntranceRecent(createdAtSeconds * 1_000) + ? MESSAGE_ENTRANCE_CLASS + : undefined; +} diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index ca3fa4f3ce..5d97149b10 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -139,7 +139,10 @@ export function ForumThreadPanel({ const scrollRef = React.useRef(null); const { channels } = useChannelNavigation(); const channelNames = React.useMemo( - () => channels.filter((c) => c.channelType !== "dm").map((c) => c.name), + () => + channels + .filter((c) => c.channelType !== "dm" && c.channelType !== "chat") + .map((c) => c.name), [channels], ); diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 341cdacf83..bc8e7f0e8e 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -506,12 +506,18 @@ export function useSendMessageMutation( mediaTags: imetaTags, emojiTags, mentionTags, + clientTags, } = splitOutgoingTags(mediaTags); - // Messages carrying media OR custom-emoji tags MUST go through REST so - // the relay's tag validation runs. The WebSocket path emits no extra - // tags, so emoji-only messages would otherwise lose their emoji tag. - if (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) { + // Messages carrying media, custom-emoji, or client marker tags MUST go + // through REST so the tag-validating builder runs. The WebSocket path + // emits no extra tags, so those sends would otherwise lose their tags. + if ( + parentEventId || + imetaTags.length > 0 || + emojiTags.length > 0 || + clientTags.length > 0 + ) { const cachedMessages = queryClient.getQueryData( channelMessagesKey(effectiveChannel.id), @@ -525,6 +531,7 @@ export function useSendMessageMutation( undefined, emojiTags, mentionTags, + clientTags, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji. diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index 1bbbdce9f9..78f661cec8 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -531,14 +531,23 @@ const MENTION_REF = [ "1111111111111111111111111111111111111111111111111111111111111111", ]; -test("splitOutgoingTags: undefined input yields three empty arrays", () => { +test("splitOutgoingTags: undefined input yields four empty arrays", () => { assert.deepEqual(splitOutgoingTags(undefined), { mediaTags: [], emojiTags: [], mentionTags: [], + clientTags: [], }); }); +test("splitOutgoingTags: routes client marker tags off the media channel", () => { + const { mediaTags, clientTags } = splitOutgoingTags([ + ["client", "automation", "ci"], + ]); + assert.deepEqual(mediaTags, []); + assert.deepEqual(clientTags, [["client", "automation", "ci"]]); +}); + test("splitOutgoingTags: separates emoji tags from imeta tags", () => { const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([ IMETA, diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index 10516555d5..873cc6fe4c 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -310,18 +310,25 @@ export function splitOutgoingTags(tags: string[][] | undefined): { mediaTags: string[][]; emojiTags: string[][]; mentionTags: string[][]; + clientTags: string[][]; } { const mediaTags: string[][] = []; const emojiTags: string[][] = []; const mentionTags: string[][] = []; + const clientTags: string[][] = []; for (const tag of tags ?? []) { if (tag[0] === "emoji") { emojiTags.push(tag); } else if (tag[0] === "mention") { mentionTags.push(tag); + } else if (tag[0] === "client") { + // Whitelisted client marker tags (e.g. automation prompts) — these + // must NOT ride the imeta-only media channel, whose builder rejects + // any non-imeta prefix and fails the whole send. + clientTags.push(tag); } else { mediaTags.push(tag); } } - return { mediaTags, emojiTags, mentionTags }; + return { mediaTags, emojiTags, mentionTags, clientTags }; } diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts new file mode 100644 index 0000000000..194a028825 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -0,0 +1,81 @@ +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { ChannelRole, UserSearchResult } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export type MentionCandidate = { + kind: "identity" | "persona"; + pubkey?: string; + personaId?: string; + displayName: string | null; + avatarUrl?: string | null; + isMember: boolean; + role?: ChannelRole | null; + personaName?: string | null; + secondaryLabel?: string | null; + ownerPubkey?: string | null; + isAgent: boolean; + isManagedAgent?: boolean; + isGlobalSearchResult?: boolean; +}; + +export function mentionCandidateLabel(candidate: MentionCandidate) { + return candidate.displayName ?? candidate.pubkey?.slice(0, 8) ?? "persona"; +} + +export function globalSearchIdentityKey(candidate: MentionCandidate) { + if ( + !candidate.isGlobalSearchResult || + candidate.isMember || + candidate.isAgent + ) { + return null; + } + + const label = candidate.displayName?.trim().toLowerCase(); + if (!label) { + return null; + } + + const secondaryLabel = candidate.secondaryLabel?.trim().toLowerCase() ?? ""; + return `global-person:${label}:${secondaryLabel}`; +} + +export function formatSearchUserDisplayName(user: UserSearchResult) { + return user.displayName?.trim() || user.nip05Handle?.trim() || null; +} + +export function formatSearchUserSecondaryLabel(user: UserSearchResult) { + const displayName = user.displayName?.trim(); + const nip05Handle = user.nip05Handle?.trim(); + + if (displayName && nip05Handle) { + return nip05Handle; + } + + return null; +} + +export function formatOwnerLabel( + ownerPubkey: string | null | undefined, + currentPubkey?: string | null, + ownerProfiles?: UserProfileLookup, +) { + if (!ownerPubkey) { + return null; + } + + const normalizedOwnerPubkey = normalizePubkey(ownerPubkey); + if ( + currentPubkey && + normalizedOwnerPubkey === normalizePubkey(currentPubkey) + ) { + return "you"; + } + + const owner = ownerProfiles?.[normalizedOwnerPubkey]; + return ( + owner?.displayName?.trim() || + owner?.nip05Handle?.trim() || + `${ownerPubkey.slice(0, 8)}…` + ); +} diff --git a/desktop/src/features/messages/lib/useChannelLinks.ts b/desktop/src/features/messages/lib/useChannelLinks.ts index 47d0701764..5464686eca 100644 --- a/desktop/src/features/messages/lib/useChannelLinks.ts +++ b/desktop/src/features/messages/lib/useChannelLinks.ts @@ -27,7 +27,10 @@ export function useChannelLinks() { /** Channel names (original casing) for overlay highlighting. */ const knownChannelNames = React.useMemo( - () => channels.filter((ch) => ch.channelType !== "dm").map((ch) => ch.name), + () => + channels + .filter((ch) => ch.channelType !== "dm" && ch.channelType !== "chat") + .map((ch) => ch.name), [channels], ); @@ -61,7 +64,9 @@ export function useChannelLinks() { return channels .filter( (ch) => - ch.channelType !== "dm" && ch.name.toLowerCase().includes(lowerQuery), + ch.channelType !== "dm" && + ch.channelType !== "chat" && + ch.name.toLowerCase().includes(lowerQuery), ) .slice(0, 8) .map((ch) => ({ diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index dfecb8fe72..9ffca36ead 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -10,6 +10,7 @@ import { useChannelsQuery, } from "@/features/channels/hooks"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; +import { useRelayMembersQuery } from "@/features/relay-members/hooks"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; import { coalesceAgentAutocompleteCandidates, @@ -21,63 +22,31 @@ import { useInfiniteUserSearchQuery, useUsersBatchQuery, } from "@/features/profile/hooks"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { AutocompleteEdit } from "./useRichTextEditor"; import type { AgentPersona, ChannelMember, - ChannelRole, ChannelType, - UserSearchResult, } from "@/shared/api/types"; -import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; import { hasMention } from "./hasMention"; +import { + formatOwnerLabel, + formatSearchUserDisplayName, + formatSearchUserSecondaryLabel, + globalSearchIdentityKey, + type MentionCandidate, + mentionCandidateLabel, +} from "./mentionCandidates"; import { rankMentionCandidates } from "./mentionRanking"; const MENTION_DEBOUNCE_MS = 120; const MENTION_SUGGESTION_LIMIT = 50; -type MentionCandidate = { - kind: "identity" | "persona"; - pubkey?: string; - personaId?: string; - displayName: string | null; - avatarUrl?: string | null; - isMember: boolean; - role?: ChannelRole | null; - personaName?: string | null; - secondaryLabel?: string | null; - ownerPubkey?: string | null; - isAgent: boolean; - isManagedAgent?: boolean; - isGlobalSearchResult?: boolean; -}; - -function mentionCandidateLabel(candidate: MentionCandidate) { - return candidate.displayName ?? candidate.pubkey?.slice(0, 8) ?? "persona"; -} - -function globalSearchIdentityKey(candidate: MentionCandidate) { - if ( - !candidate.isGlobalSearchResult || - candidate.isMember || - candidate.isAgent - ) { - return null; - } - - const label = candidate.displayName?.trim().toLowerCase(); - if (!label) { - return null; - } - - const secondaryLabel = candidate.secondaryLabel?.trim().toLowerCase() ?? ""; - return `global-person:${label}:${secondaryLabel}`; -} - export type PersonaMentionTarget = { displayName: string; persona: AgentPersona; @@ -87,46 +56,6 @@ type UseMentionsOptions = { channelType?: ChannelType | null; }; -function formatSearchUserDisplayName(user: UserSearchResult) { - return user.displayName?.trim() || user.nip05Handle?.trim() || null; -} - -function formatSearchUserSecondaryLabel(user: UserSearchResult) { - const displayName = user.displayName?.trim(); - const nip05Handle = user.nip05Handle?.trim(); - - if (displayName && nip05Handle) { - return nip05Handle; - } - - return null; -} - -function formatOwnerLabel( - ownerPubkey: string | null | undefined, - currentPubkey: string | null | undefined, - ownerProfiles?: UserProfileLookup, -) { - if (!ownerPubkey) { - return null; - } - - const normalizedOwnerPubkey = normalizePubkey(ownerPubkey); - if ( - currentPubkey && - normalizedOwnerPubkey === normalizePubkey(currentPubkey) - ) { - return "you"; - } - - const owner = ownerProfiles?.[normalizedOwnerPubkey]; - return ( - owner?.displayName?.trim() || - owner?.nip05Handle?.trim() || - `${ownerPubkey.slice(0, 8)}…` - ); -} - export function useMentions( channelId: string | null, externalMembers?: ChannelMember[], @@ -145,15 +74,21 @@ export function useMentions( const personaMentionMapRef = React.useRef>(new Map()); const previousSuggestionsRef = React.useRef([]); - void options?.channelType; + const canSearchAllUsers = + options?.channelType === "dm" || + options?.channelType === "stream" || + options?.channelType === "forum" || + options?.channelType === "chat"; const mentionSearchQuery = mentionQuery?.trim() ?? ""; - const canSearchGlobalPeople = mentionSearchQuery.length > 0; + const canSearchGlobalPeople = + canSearchAllUsers && mentionSearchQuery.length > 0; const identityQuery = useIdentityQuery(); const currentPubkey = identityQuery.data?.pubkey ? normalizePubkey(identityQuery.data.pubkey) : null; const membersQuery = useChannelMembersQuery(channelId); const members = externalMembers ?? membersQuery.data; + const relayMembersQuery = useRelayMembersQuery(canSearchAllUsers); const isArchivedDiscovery = useIsArchivedPredicate(); const managedAgentsQuery = useManagedAgentsQuery(); const relayAgentsQuery = useRelayAgentsQuery(); @@ -220,6 +155,16 @@ export function useMentions( ), [managedAgentsQuery.data], ); + const relayMemberPubkeys = React.useMemo( + () => + (relayMembersQuery.data ?? []).map((member) => + normalizePubkey(member.pubkey), + ), + [relayMembersQuery.data], + ); + const relayMemberProfilesQuery = useUsersBatchQuery(relayMemberPubkeys, { + enabled: canSearchAllUsers && relayMemberPubkeys.length > 0, + }); const relayAgentNamesByPubkey = React.useMemo( () => new Map( @@ -365,6 +310,43 @@ export function useMentions( }); } + if (canSearchAllUsers) { + for (const relayMember of relayMembersQuery.data ?? []) { + const pubkey = normalizePubkey(relayMember.pubkey); + const agentName = + managedAgentNamesByPubkey.get(pubkey) ?? + relayAgentNamesByPubkey.get(pubkey) ?? + null; + const profile = + profiles?.[pubkey] ?? + relayMemberProfilesQuery.data?.profiles[pubkey] ?? + null; + addCandidate({ + kind: "identity", + pubkey, + displayName: + agentName || + profile?.displayName?.trim() || + profile?.nip05Handle?.trim() || + null, + avatarUrl: profile?.avatarUrl ?? null, + isMember: memberPubkeys.has(pubkey), + isAgent: + profile?.isAgent === true || + managedAgentNamesByPubkey.has(pubkey) || + relayAgentNamesByPubkey.has(pubkey), + personaId: managedAgentPersonaIdsByPubkey.get(pubkey), + personaName: personaNameByPubkey.get(pubkey) ?? null, + secondaryLabel: + profile?.displayName?.trim() && profile?.nip05Handle?.trim() + ? profile.nip05Handle + : null, + ownerPubkey: profile?.ownerPubkey ?? null, + isManagedAgent: managedAgentNamesByPubkey.has(pubkey), + }); + } + } + for (const agent of relayAgentsQuery.data ?? []) { addCandidate({ kind: "identity", @@ -444,6 +426,7 @@ export function useMentions( }, [ activePersonas, userSearchResults, + canSearchAllUsers, canSearchGlobalUsers, currentPubkey, isArchivedDiscovery, @@ -458,6 +441,8 @@ export function useMentions( profiles, relayAgentNamesByPubkey, relayAgentsQuery.data, + relayMemberProfilesQuery.data?.profiles, + relayMembersQuery.data, ]); const ownerPubkeys = React.useMemo( diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index d483ab2219..24ed38993a 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -8,6 +8,7 @@ import { Link2, MailCheck, MailOpen, + MessageCircle, Pencil, SmilePlus, Trash2, @@ -67,6 +68,7 @@ function MoreActionsMenu({ onMarkRead, onOpenChange, onRemindLater, + onStartSideConversation, onUnfollowThread, open, isFollowingThread, @@ -83,6 +85,7 @@ function MoreActionsMenu({ onMarkRead?: (message: TimelineMessage) => void; onOpenChange: (open: boolean) => void; onRemindLater?: (message: TimelineMessage) => void; + onStartSideConversation?: (message: TimelineMessage) => void; onUnfollowThread?: (message: TimelineMessage) => void; open: boolean; isFollowingThread?: boolean; @@ -210,6 +213,18 @@ function MoreActionsMenu({ ) : null} + {hasCopyActions && onStartSideConversation ? ( + { + onStartSideConversation(message); + }} + > + + Side conversation + + ) : null} + {hasCopyActions && channelId ? ( Promise; onRemindLater?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; + onStartSideConversation?: (message: TimelineMessage) => void; onUnfollowThread?: (message: TimelineMessage) => void; reactionErrorMessage?: string | null; reactions: TimelineReaction[]; @@ -392,6 +409,7 @@ export function MessageActionBar({ Boolean(onFollowThread) || Boolean(onUnfollowThread) || Boolean(onRemindLater) || + Boolean(onStartSideConversation) || !message.pending; const wouldAddReaction = React.useCallback( @@ -539,6 +557,7 @@ export function MessageActionBar({ onMarkRead={onMarkRead} onOpenChange={setIsDropdownOpen} onRemindLater={onRemindLater} + onStartSideConversation={onStartSideConversation} onUnfollowThread={onUnfollowThread} open={isDropdownOpen} isFollowingThread={isFollowingThread} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 9a3f5ad047..9ad42af8a3 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -19,12 +19,8 @@ import { } from "@/features/messages/lib/imetaMediaMarkdown"; import { useAttachmentEditing } from "@/features/messages/lib/useAttachmentEditing"; -import { - type MediaUploadController, - useMediaUpload, -} from "@/features/messages/lib/useMediaUpload"; +import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { useMentions } from "@/features/messages/lib/useMentions"; -import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { hasMentionClipboardHtml, normalizeMentionClipboardHtml, @@ -40,7 +36,6 @@ import { useComposerSpoilerParticles } from "@/features/messages/lib/useComposer import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; import { cn } from "@/shared/lib/cn"; -import type { ChannelType } from "@/shared/api/types"; import { ChannelAutocomplete } from "./ChannelAutocomplete"; import { ComposerReplyEditBanner } from "./ComposerReplyEditBanner"; import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments"; @@ -54,75 +49,10 @@ import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useMentionSendFlow } from "./useMentionSendFlow"; import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; - -type MessageComposerProps = { - channelId?: string | null; - channelName: string; - channelType?: ChannelType | null; - containerClassName?: string; - disabled?: boolean; - draftKey?: string; - editTarget?: { - author: string; - body: string; - id: string; - /** - * NIP-92 imeta attachments on the original event, in tag order. Loaded - * into the composer's pending-imeta state on edit-open so the user sees - * them as removable thumbnails (just like the send path) and can add - * more. The submit path emits a fresh full imeta tag set on the edit - * event; the receiver overlays it. - */ - imetaMedia?: ImetaMedia[]; - } | null; - isSending?: boolean; - mediaController?: MediaUploadController; - onCancelEdit?: () => void; - onCancelReply?: () => void; - /** - * Invoked when the user presses ↑ in an empty composer that is not already - * in edit mode. The owner should locate the most recent message authored by - * the current user within this composer's scope (main timeline, DM, or - * thread) and enter edit mode for it. Return `true` if a target was found - * and edit mode was entered, so the composer can swallow the keystroke; - * return `false` to let the arrow key fall through normally. - */ - onEditLastOwnMessage?: () => boolean; - onEditSave?: (content: string, mediaTags?: string[][]) => Promise; - /** - * Called synchronously at the start of `submitMessage`, before any awaits, - * to capture context that must be stable throughout the async send pipeline. - * Used by the thread-reply composer to capture the current reply target before - * the mention-flow awaits can change navigation state. - */ - onCaptureSendContext?: () => { - parentEventId: string | null; - threadHeadId: string | null; - } | null; - onSend: ( - content: string, - mentionPubkeys: string[], - mediaTags?: string[][], - channelId?: string | null, - threadContext?: { - parentEventId: string | null; - threadHeadId: string | null; - } | null, - ) => Promise; - placeholder?: string; - profiles?: UserProfileLookup; - replyTarget?: { - author: string; - body: string; - id: string; - } | null; - showTopBorder?: boolean; - toolbarExtraActions?: React.ReactNode; - typingParentEventId?: string | null; - typingRootEventId?: string | null; -}; +import type { MessageComposerProps } from "./messageComposerProps"; function MessageComposerImpl({ + autoInviteNonMemberMentions = false, channelId = null, channelName, channelType = null, @@ -137,11 +67,13 @@ function MessageComposerImpl({ onEditLastOwnMessage, onEditSave, onSend, + onStopAgent = null, placeholder, profiles, replyTarget = null, mediaController, showTopBorder = false, + toolbarControls, toolbarExtraActions, typingParentEventId = null, typingRootEventId = null, @@ -161,11 +93,33 @@ function MessageComposerImpl({ >(() => new Set()); const spoileredAttachmentUrlsRef = React.useRef(spoileredAttachmentUrls); spoileredAttachmentUrlsRef.current = spoileredAttachmentUrls; + const showEmojiControls = toolbarControls?.emoji ?? true; + const showFormattingControls = toolbarControls?.formatting ?? true; + const showSpoilerControls = toolbarControls?.spoiler ?? true; + + const handleFormattingToggle = React.useCallback( + (pressed: boolean) => { + if (!showFormattingControls) { + setIsFormattingOpen(false); + return; + } + if (pressed) setIsEmojiPickerOpen(false); + setIsFormattingOpen(pressed); + }, + [showFormattingControls], + ); - const handleFormattingToggle = React.useCallback((pressed: boolean) => { - if (pressed) setIsEmojiPickerOpen(false); - setIsFormattingOpen(pressed); - }, []); + React.useEffect(() => { + if (!showFormattingControls) { + setIsFormattingOpen(false); + } + }, [showFormattingControls]); + + React.useEffect(() => { + if (!showSpoilerControls) { + setSpoileredAttachmentUrls(new Set()); + } + }, [showSpoilerControls]); const drafts = useDrafts(); const effectiveDraftKey = draftKey ?? channelId; @@ -183,6 +137,12 @@ function MessageComposerImpl({ const channelLinks = useChannelLinks(); const customEmoji = useCustomEmoji(); const emojiAutocomplete = useEmojiAutocomplete(customEmoji); + React.useEffect(() => { + if (!showEmojiControls) { + setIsEmojiPickerOpen(false); + emojiAutocomplete.clearEmojis(); + } + }, [emojiAutocomplete.clearEmojis, showEmojiControls]); const notifyTyping = useTypingBroadcast( channelId, typingParentEventId, @@ -195,29 +155,6 @@ function MessageComposerImpl({ const media = mediaController ?? internalMedia; const ownsDropZone = mediaController === undefined; - // Draft-persist lifecycle: restore/clear content + imeta + spoilered urls on - // key change, and persist the outgoing draft in the cleanup. The StrictMode - // fix lives inside this hook — see useDraftPersistSnapshot.ts. - useDraftPersistLifecycle({ - effectiveDraftKey, - channelId, - loadDraft: drafts.loadDraft, - persistDraft: drafts.persistDraft, - livePendingImeta: media.pendingImeta, - setPendingImeta: media.setPendingImeta, - setContent: (content) => { - setComposerContent(content); - richText.setContent(content); - }, - clearContent: () => { - setComposerContent(""); - richText.clearContent(); - }, - setSpoileredAttachmentUrls, - spoileredAttachmentUrlsRef, - syncComposerContentFromEditor, - }); - // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useEffect(() => { media.setUploadState({ status: "idle" }); @@ -246,7 +183,7 @@ function MessageComposerImpl({ isAutocompleteOpenRef.current = mentions.isMentionOpen || channelLinks.isChannelOpen || - emojiAutocomplete.isEmojiAutocompleteOpen; + (showEmojiControls && emojiAutocomplete.isEmojiAutocompleteOpen); const submitMessageRef = React.useRef<() => void>(() => {}); const composerScrollRef = React.useRef(null); @@ -299,7 +236,11 @@ function MessageComposerImpl({ mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); - emojiAutocomplete.updateEmojiQuery(text, cursor); + if (showEmojiControls) { + emojiAutocomplete.updateEmojiQuery(text, cursor); + } else { + emojiAutocomplete.clearEmojis(); + } if (text.trim().length > 0) { notifyTyping(); @@ -317,6 +258,29 @@ function MessageComposerImpl({ onLinkSelectionChangeRef.current = linkEditor.showFromCursor; useComposerSpoilerParticles(richText.editor, composerScrollRef); + // Draft-persist lifecycle: restore/clear content + imeta + spoilered urls on + // key change, and persist the outgoing draft in the cleanup. The StrictMode + // fix lives inside this hook — see useDraftPersistSnapshot.ts. + useDraftPersistLifecycle({ + effectiveDraftKey, + channelId, + loadDraft: drafts.loadDraft, + persistDraft: drafts.persistDraft, + livePendingImeta: media.pendingImeta, + setPendingImeta: media.setPendingImeta, + setContent: (content) => { + setComposerContent(content); + richText.setContent(content); + }, + clearContent: () => { + setComposerContent(""); + richText.clearContent(); + }, + setSpoileredAttachmentUrls, + spoileredAttachmentUrlsRef, + syncComposerContentFromEditor, + }); + const mentionSendFlow = useMentionSendFlow({ channelId, channelLinks, @@ -328,6 +292,7 @@ function MessageComposerImpl({ mentions, onSendRef, richText, + autoInviteNonMemberMentions, setContent: setComposerContent, setIsEmojiPickerOpen, setPendingImeta: media.setPendingImeta, @@ -642,20 +607,18 @@ function MessageComposerImpl({ [submitMessage], ); - // ── Keyboard handling ─────────────────────────────────────────────── - // Tiptap handles formatting shortcuts (⌘B, ⌘I, etc.) natively. - // Plain Enter → submit is now handled inside the Tiptap `submitOnEnter` - // extension (fires before ProseMirror's splitBlock). This wrapper only - // handles autocomplete arrow/enter keys and Escape for edit mode. + // Plain Enter submits inside Tiptap; this wrapper handles autocomplete keys and Escape. const handleEditorKeyDown = React.useCallback( (event: React.KeyboardEvent) => { // Let autocomplete handle keys first - const emojiResult = emojiAutocomplete.handleEmojiKeyDown(event); - if (emojiResult.handled) { - if (emojiResult.suggestion) { - applyEmojiInsert(emojiResult.suggestion); + if (showEmojiControls) { + const emojiResult = emojiAutocomplete.handleEmojiKeyDown(event); + if (emojiResult.handled) { + if (emojiResult.suggestion) { + applyEmojiInsert(emojiResult.suggestion); + } + return; } - return; } const channelResult = channelLinks.handleChannelKeyDown(event); @@ -692,6 +655,7 @@ function MessageComposerImpl({ [ emojiAutocomplete.handleEmojiKeyDown, applyEmojiInsert, + showEmojiControls, channelLinks.handleChannelKeyDown, applyChannelInsert, mentions.handleMentionKeyDown, @@ -873,15 +837,17 @@ function MessageComposerImpl({ }} > {ownsDropZone && media.isDragOver && } - + {showEmojiControls ? ( + + ) : null} 0} />
diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index 1c8087e992..f7efac52c4 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -1,12 +1,25 @@ import * as React from "react"; import type { Editor } from "@tiptap/react"; import { AnimatePresence, motion } from "motion/react"; -import { ALargeSmall, ArrowUp, AtSign, Paperclip, X } from "lucide-react"; +import { + ALargeSmall, + ArrowUp, + AtSign, + HatGlasses, + Paperclip, + Square, + X, +} from "lucide-react"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { ComposerEmojiPicker } from "./ComposerEmojiPicker"; -import { FormattingToolbar } from "./FormattingToolbar"; +import { + FormattingToolbar, + isSpoilerFormattingActive, + toggleSpoilerFormatting, +} from "./FormattingToolbar"; import { SelectionFormattingTray } from "./SelectionFormattingTray"; /** Spring for enter/exit of button groups — all fire simultaneously. */ @@ -33,7 +46,12 @@ export const MessageComposerToolbar = React.memo( onLinkButton, onOpenMentionPicker, onPaperclip, + onStopAgent, sendDisabled, + showEmojiPicker, + showFormatting, + showSpoiler, + spoilerActive, }: { composerDisabled: boolean; editor: Editor | null; @@ -50,15 +68,58 @@ export const MessageComposerToolbar = React.memo( onLinkButton: () => void; onOpenMentionPicker: () => void; onPaperclip: () => void; + /** + * When set, the send slot renders a stop button that interrupts the + * channel's working agent (send stays reachable while a user message is + * in flight — `isSending` wins). + */ + onStopAgent?: (() => void) | null; sendDisabled: boolean; + showEmojiPicker?: boolean; + showFormatting?: boolean; + showSpoiler?: boolean; + spoilerActive?: boolean; }) { + const shouldShowFormatting = showFormatting ?? true; + const shouldShowEmojiPicker = showEmojiPicker ?? true; + const shouldShowSpoiler = showSpoiler ?? true; + const [spoilerFormattingActive, setSpoilerFormattingActive] = + React.useState(() => + editor ? isSpoilerFormattingActive(editor) : false, + ); + + React.useEffect(() => { + if (!editor) { + setSpoilerFormattingActive(false); + return; + } + + const update = () => { + setSpoilerFormattingActive(isSpoilerFormattingActive(editor)); + }; + update(); + editor.on("transaction", update); + return () => { + editor.off("transaction", update); + }; + }, [editor]); + + const isSpoilerActive = spoilerFormattingActive || Boolean(spoilerActive); + + const handleSpoilerClick = React.useCallback(() => { + if (!editor) return; + toggleSpoilerFormatting(editor); + }, [editor]); + return (
- + {shouldShowFormatting ? ( + + ) : null}
{/* * AnimatePresence with mode="popLayout" — exiting elements @@ -70,7 +131,7 @@ export const MessageComposerToolbar = React.memo( * no order hacks, no overflow clipping needed. */} - {isFormattingOpen ? ( + {isFormattingOpen && shouldShowFormatting ? ( /* * ── Expanded: [Aa] [✕] | [formatting buttons] ── */ @@ -192,38 +253,65 @@ export const MessageComposerToolbar = React.memo( Attach image - editor?.commands.focus()} - onEmojiSelect={onEmojiSelect} - onOpenChange={onEmojiPickerOpenChange} - onTriggerMouseDown={onCaptureSelection} - open={isEmojiPickerOpen} - /> - + {shouldShowEmojiPicker ? ( + editor?.commands.focus()} + onEmojiSelect={onEmojiSelect} + onOpenChange={onEmojiPickerOpenChange} + onTriggerMouseDown={onCaptureSelection} + open={isEmojiPickerOpen} + /> + ) : null} + {shouldShowSpoiler ? ( - Formatting + Spoiler - + ) : null} + {shouldShowFormatting ? ( + + + + + + Formatting + + + ) : null} )} @@ -231,23 +319,39 @@ export const MessageComposerToolbar = React.memo(
{extraActions} - + {onStopAgent && !isSending ? ( + + ) : ( + + )}
); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index ac1cd93991..53d8ad45ef 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -81,6 +81,7 @@ export const MessageRow = React.memo( onMarkRead, onToggleReaction, onReply, + onStartSideConversation, onUnfollowThread, profiles, searchQuery, @@ -128,6 +129,7 @@ export const MessageRow = React.memo( remove: boolean, ) => Promise; onReply?: (message: TimelineMessage) => void; + onStartSideConversation?: (message: TimelineMessage) => void; onUnfollowThread?: (message: TimelineMessage) => void; profiles?: UserProfileLookup; searchQuery?: string; @@ -202,7 +204,10 @@ export const MessageRow = React.memo( const { channels } = useChannelNavigation(); const channelNames = React.useMemo( - () => channels.filter((c) => c.channelType !== "dm").map((c) => c.name), + () => + channels + .filter((c) => c.channelType !== "dm" && c.channelType !== "chat") + .map((c) => c.name), [channels], ); @@ -461,6 +466,7 @@ export const MessageRow = React.memo( }); }} onReply={onReply} + onStartSideConversation={onStartSideConversation} onUnfollowThread={onUnfollowThread} reactionErrorMessage={reactionErrorMessage} reactions={reactions} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 09041e5e29..337681f7a7 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -95,6 +95,7 @@ type MessageThreadPanelProps = { isFollowingThread?: boolean; isMessageUnreadById?: (messageId: string) => boolean; onFollowThread?: () => void; + onStartSideConversation?: (message: TimelineMessage) => void; onUnfollowThread?: () => void; }; @@ -313,6 +314,7 @@ export function MessageThreadPanel({ onSelectReplyTarget, onSend, onToggleReaction, + onStartSideConversation, onUnfollowThread, profiles, replyTargetMessage, @@ -632,6 +634,7 @@ export function MessageThreadPanel({ onMarkUnread={onMarkUnread} onMarkRead={onMarkRead} onToggleReaction={onToggleReaction} + onStartSideConversation={onStartSideConversation} onUnfollowThread={ onUnfollowThread ? (_msg) => onUnfollowThread() : undefined } @@ -773,6 +776,7 @@ export function MessageThreadPanel({ } onMarkUnread={onMarkUnread} onMarkRead={onMarkRead} + onStartSideConversation={onStartSideConversation} onReply={onSelectReplyTarget} onToggleReaction={onToggleReaction} profiles={profiles} diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index e007e3f915..525abaeb60 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -69,6 +69,7 @@ type MessageTimelineProps = { onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; + onStartSideConversation?: (message: TimelineMessage) => void; isSendingVideoReviewComment?: boolean; onSendVideoReviewComment?: ( message: TimelineMessage, @@ -169,6 +170,7 @@ const MessageTimelineBase = React.forwardRef< onMarkUnread, onMarkRead, onReply, + onStartSideConversation, channelName, channelType, isSendingVideoReviewComment = false, @@ -604,6 +606,7 @@ const MessageTimelineBase = React.forwardRef< onMarkUnread={onMarkUnread} onMarkRead={onMarkRead} onReply={onReply} + onStartSideConversation={onStartSideConversation} isSendingVideoReviewComment={isSendingVideoReviewComment} onSendVideoReviewComment={onSendVideoReviewComment} onToggleReaction={onToggleReaction} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index fdc9679104..ebaa61fea7 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -56,6 +56,7 @@ type TimelineMessageListProps = { onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; + onStartSideConversation?: (message: TimelineMessage) => void; isSendingVideoReviewComment?: boolean; onSendVideoReviewComment?: ( message: TimelineMessage, @@ -105,6 +106,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onMarkUnread, onMarkRead, onReply, + onStartSideConversation, isSendingVideoReviewComment = false, onSendVideoReviewComment, onToggleReaction, @@ -214,6 +216,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onMarkRead={onMarkRead} onMarkUnread={onMarkUnread} onReply={onReply} + onStartSideConversation={onStartSideConversation} onToggleReaction={onToggleReaction} profiles={profiles} searchActiveMessageId={searchActiveMessageId} @@ -244,6 +247,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onMarkRead, onMarkUnread, onReply, + onStartSideConversation, onToggleReaction, profiles, searchActiveMessageId, @@ -331,6 +335,7 @@ type MessageRowItemProps = Pick< | "onMarkUnread" | "onMarkRead" | "onReply" + | "onStartSideConversation" | "onToggleReaction" | "profiles" | "searchActiveMessageId" @@ -366,6 +371,7 @@ function MessageRowItem({ onMarkUnread, onMarkRead, onReply, + onStartSideConversation, onToggleReaction, profiles, searchActiveMessageId, @@ -418,6 +424,7 @@ function MessageRowItem({ onMarkUnread={onMarkUnread} onToggleReaction={onToggleReaction} onReply={onReply} + onStartSideConversation={onStartSideConversation} onUnfollowThread={ unfollowThreadById ? () => unfollowThreadById(message.id) @@ -466,6 +473,7 @@ function MessageRowItem({ onMarkUnread={onMarkUnread} onToggleReaction={onToggleReaction} onReply={onReply} + onStartSideConversation={onStartSideConversation} profiles={profiles} searchQuery={isSearchMatch ? searchQuery : undefined} showDepthGuides={false} diff --git a/desktop/src/features/messages/ui/messageComposerProps.ts b/desktop/src/features/messages/ui/messageComposerProps.ts new file mode 100644 index 0000000000..9a026a3e8b --- /dev/null +++ b/desktop/src/features/messages/ui/messageComposerProps.ts @@ -0,0 +1,84 @@ +import type * as React from "react"; + +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { MediaUploadController } from "@/features/messages/lib/useMediaUpload"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { ChannelType } from "@/shared/api/types"; + +export type MessageComposerProps = { + autoInviteNonMemberMentions?: boolean; + channelId?: string | null; + channelName: string; + channelType?: ChannelType | null; + containerClassName?: string; + disabled?: boolean; + draftKey?: string; + editTarget?: { + author: string; + body: string; + id: string; + /** + * NIP-92 imeta attachments on the original event, in tag order. Loaded + * into the composer's pending-imeta state on edit-open so the user sees + * them as removable thumbnails (just like the send path) and can add + * more. The submit path emits a fresh full imeta tag set on the edit + * event; the receiver overlays it. + */ + imetaMedia?: ImetaMedia[]; + } | null; + isSending?: boolean; + mediaController?: MediaUploadController; + onCancelEdit?: () => void; + onCancelReply?: () => void; + /** + * Invoked when the user presses ↑ in an empty composer that is not already + * in edit mode. The owner should locate the most recent message authored by + * the current user within this composer's scope (main timeline, DM, or + * thread) and enter edit mode for it. Return `true` if a target was found + * and edit mode was entered, so the composer can swallow the keystroke; + * return `false` to let the arrow key fall through normally. + */ + onEditLastOwnMessage?: () => boolean; + onEditSave?: (content: string, mediaTags?: string[][]) => Promise; + /** + * Called synchronously at the start of `submitMessage`, before any awaits, + * to capture context that must be stable throughout the async send pipeline. + * Used by the thread-reply composer to capture the current reply target before + * the mention-flow awaits can change navigation state. + */ + onCaptureSendContext?: () => { + parentEventId: string | null; + threadHeadId: string | null; + } | null; + onSend: ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + channelId?: string | null, + threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + ) => Promise; + /** + * When set, the send button becomes a stop button that interrupts the + * channel's working agent. Pass only while a turn is actually active. + */ + onStopAgent?: (() => void) | null; + placeholder?: string; + profiles?: UserProfileLookup; + replyTarget?: { + author: string; + body: string; + id: string; + } | null; + showTopBorder?: boolean; + toolbarControls?: { + emoji?: boolean; + formatting?: boolean; + spoiler?: boolean; + }; + toolbarExtraActions?: React.ReactNode; + typingParentEventId?: string | null; + typingRootEventId?: string | null; +}; diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 2c0176df7f..9146c1c496 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -225,6 +225,27 @@ export function useAnchoredScroll({ container.scrollTo({ top: container.scrollHeight, behavior }); setIsAtBottom(true); setNewMessageCount(0); + + // The jump animates toward a scrollHeight snapshot, but virtualized + // rows re-measure mid-flight (estimate → real height), moving the + // floor. Re-assert the bottom until it stabilizes; re-issuing a + // smooth scrollTo re-targets the ongoing glide rather than snapping. + let attempts = 0; + const reassertBottom = () => { + if (anchorRef.current.kind !== "at-bottom") return; + const element = scrollContainerRef.current; + if (!element) return; + const gap = + element.scrollHeight - element.scrollTop - element.clientHeight; + if (gap > 1) { + element.scrollTo({ top: element.scrollHeight, behavior }); + } + attempts += 1; + if (attempts < 10 && (gap > 1 || attempts < 3)) { + window.setTimeout(reassertBottom, 120); + } + }; + window.setTimeout(reassertBottom, 120); }, [scrollContainerRef], ); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 429921a282..f85482496b 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -58,6 +58,7 @@ type SendMessageWithMentionFlowInput = { }; type UseMentionSendFlowOptions = { + autoInviteNonMemberMentions?: boolean; channelId: string | null; channelLinks: Pick; channelType: ChannelType | null; @@ -119,6 +120,7 @@ function isProviderBackedAgent(agent: ManagedAgent) { } export function useMentionSendFlow({ + autoInviteNonMemberMentions = false, channelId, channelLinks, channelType, @@ -453,6 +455,46 @@ export function useMentionSendFlow({ [channelType, mentions.hasResolvedMembers, mentions.memberPubkeys], ); + const inviteNonMemberPubkeys = React.useCallback( + async (pubkeys: string[]) => { + const managedAgentsByPubkey = await getManagedAgentsByPubkey(); + const peoplePubkeys: string[] = []; + const relayAgentPubkeys: string[] = []; + + for (const pubkey of uniqueNormalizedPubkeys(pubkeys)) { + if (managedAgentsByPubkey.has(pubkey)) { + continue; + } + + if (mentions.isAgentPubkey(pubkey)) { + relayAgentPubkeys.push(pubkey); + } else { + peoplePubkeys.push(pubkey); + } + } + + const errors: string[] = []; + if (peoplePubkeys.length > 0) { + const result = await addMembersMutation.mutateAsync({ + pubkeys: peoplePubkeys, + role: "member", + }); + errors.push(...result.errors.map((error) => error.error)); + } + + if (relayAgentPubkeys.length > 0) { + const result = await addMembersMutation.mutateAsync({ + pubkeys: relayAgentPubkeys, + role: "bot", + }); + errors.push(...result.errors.map((error) => error.error)); + } + + return errors; + }, + [addMembersMutation, getManagedAgentsByPubkey, mentions.isAgentPubkey], + ); + const sendMessageWithMentionFlow = React.useCallback( async ({ capturedChannelId, @@ -536,6 +578,21 @@ export function useMentionSendFlow({ }; if (promptNonMemberPubkeys.length > 0) { + if (autoInviteNonMemberMentions) { + const errors = await inviteNonMemberPubkeys(promptNonMemberPubkeys); + if (errors.length > 0) { + const message = errors.join("; "); + setNonMemberPromptError(message); + toast.error("Could not share chat", { + description: message, + }); + return; + } + + await completeSend(pendingDraft, pubkeys, outgoingTags); + return; + } + setNonMemberPromptError(null); setPendingNonMemberSend(pendingDraft); return; @@ -551,8 +608,10 @@ export function useMentionSendFlow({ completeSend, createMentionedPersonaAgents, customEmoji, + autoInviteNonMemberMentions, getManagedAgentsByPubkey, getNonMemberMentionPubkeys, + inviteNonMemberPubkeys, mentions.extractMentionPubkeys, mentions.isManagedAgentPubkey, ], @@ -602,43 +661,9 @@ export function useMentionSendFlow({ setNonMemberPromptError(null); void (async () => { - const managedAgentsByPubkey = await getManagedAgentsByPubkey(); - const peoplePubkeys: string[] = []; - const relayAgentPubkeys: string[] = []; - - for (const pubkey of uniqueNormalizedPubkeys( + const errors = await inviteNonMemberPubkeys( pendingNonMemberSend.nonMemberPubkeys, - )) { - if (managedAgentsByPubkey.has(pubkey)) { - continue; - } - - if (mentions.isAgentPubkey(pubkey)) { - relayAgentPubkeys.push(pubkey); - } else { - peoplePubkeys.push(pubkey); - } - } - - const errors: string[] = []; - if (peoplePubkeys.length > 0) { - const result = await addMembersMutation.mutateAsync({ - channelId: pendingNonMemberSend.capturedChannelId ?? undefined, - pubkeys: peoplePubkeys, - role: "member", - }); - errors.push(...result.errors.map((error) => error.error)); - } - - if (relayAgentPubkeys.length > 0) { - const result = await addMembersMutation.mutateAsync({ - channelId: pendingNonMemberSend.capturedChannelId ?? undefined, - pubkeys: relayAgentPubkeys, - role: "bot", - }); - errors.push(...result.errors.map((error) => error.error)); - } - + ); if (errors.length > 0) { setNonMemberPromptError(errors.join("; ")); return; @@ -658,13 +683,7 @@ export function useMentionSendFlow({ error instanceof Error ? error.message : "Could not invite members.", ); }); - }, [ - addMembersMutation, - completeSend, - getManagedAgentsByPubkey, - mentions.isAgentPubkey, - pendingNonMemberSend, - ]); + }, [completeSend, inviteNonMemberPubkeys, pendingNonMemberSend]); const dismissNonMemberPrompt = React.useCallback(() => { setPendingNonMemberSend(null); diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts index 4ce2b4969a..92839dd745 100644 --- a/desktop/src/features/onboarding/welcomeGuide.ts +++ b/desktop/src/features/onboarding/welcomeGuide.ts @@ -3,6 +3,7 @@ import { createManagedAgent, getChannelMembers, listManagedAgents, + startManagedAgent, } from "@/shared/api/tauri"; import { sendManagedAgentChannelMessage } from "@/shared/api/tauriManagedAgentMessages"; import { listPersonas, setPersonaActive } from "@/shared/api/tauriPersonas"; @@ -164,3 +165,17 @@ export async function ensureWelcomeGuideIntro( }); return agent; } + +export async function ensureWelcomeGuideAgentInChannel( + channelId: string, + relayUrl?: string | null, +) { + const agent = await ensureWelcomeGuideAgent(relayUrl); + await ensureWelcomeGuideMembership(channelId, agent); + + if (agent.status === "running") { + return agent; + } + + return startManagedAgent(agent.pubkey); +} diff --git a/desktop/src/features/search/useChannelFind.ts b/desktop/src/features/search/useChannelFind.ts index fbe23bb5b1..71fc97db55 100644 --- a/desktop/src/features/search/useChannelFind.ts +++ b/desktop/src/features/search/useChannelFind.ts @@ -2,7 +2,8 @@ import * as React from "react"; import { useSearchMessagesQuery } from "@/features/search/hooks"; import type { TimelineMessage } from "@/features/messages/types"; -import type { SearchHit } from "@/shared/api/types"; +import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; +import type { RelayEvent, SearchHit } from "@/shared/api/types"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; const MIN_QUERY_LENGTH = 2; @@ -188,3 +189,28 @@ export function useChannelFind({ ], ); } + +/** + * Ownership of the "find target" events: search hits outside the loaded + * window are cached and spliced into the timeline, and cleared when the + * channel changes. Split from useChannelFind because the spliced events + * feed the very message list the find hook searches. + */ +export function useFindTargetEvents(channelId: string | null) { + const [findTargetEvents, setFindTargetEvents] = React.useState( + [], + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: clear spliced find results exactly when the active channel changes. + React.useEffect(() => { + setFindTargetEvents([]); + }, [channelId]); + const handleFindSearchHit = React.useCallback((hit: SearchHit) => { + const event = cacheSearchHitEvent(hit); + setFindTargetEvents((currentEvents) => + currentEvents.some((currentEvent) => currentEvent.id === event.id) + ? currentEvents + : [...currentEvents, event], + ); + }, []); + return { findTargetEvents, handleFindSearchHit }; +} diff --git a/desktop/src/features/settings/ui/MobilePairingCard.tsx b/desktop/src/features/settings/ui/MobilePairingCard.tsx index fab4aa1589..924f6eed60 100644 --- a/desktop/src/features/settings/ui/MobilePairingCard.tsx +++ b/desktop/src/features/settings/ui/MobilePairingCard.tsx @@ -17,7 +17,7 @@ import { cancelPairing, confirmPairingSas, startPairing, -} from "@/shared/api/tauri"; +} from "@/shared/api/tauriPairing"; import { Button } from "@/shared/ui/button"; import { Dialog, diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index a4f3b4d6a5..8b4dea78fa 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -100,6 +100,7 @@ type AppSidebarProps = { selectedView: | "home" | "channel" + | "chats" | "agents" | "workflows" | "pulse" @@ -140,6 +141,7 @@ type AppSidebarProps = { onRemoveWorkspace: (id: string) => void; onCreateAgent: () => void; onSelectAgents: () => void; + onSelectChats: () => void; onSelectProjects: () => void; onSelectPulse: () => void; onSelectWorkflows: () => void; @@ -207,6 +209,7 @@ export function AppSidebar({ onRemoveWorkspace, onCreateAgent, onSelectAgents, + onSelectChats, onSelectProjects, onSelectPulse, onSelectWorkflows, @@ -556,6 +559,7 @@ export function AppSidebar({ onOpenDm={onOpenDm} onOpenSearchResult={onOpenSearchResult} onSelectAgents={onSelectAgents} + onSelectChats={onSelectChats} onSelectChannel={onSelectChannel} onSelectHome={onSelectHome} onSelectProjects={onSelectProjects} diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 2e9dbbd7f4..2c2299acf2 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -1,4 +1,11 @@ -import { Activity, Bot, FolderGit2, Inbox, Zap } from "lucide-react"; +import { + Activity, + Bot, + FolderGit2, + Inbox, + MessageCircle, + Zap, +} from "lucide-react"; import { TopbarSearch } from "@/features/search/ui/TopbarSearch"; import { FeatureGate } from "@/shared/features"; @@ -14,6 +21,7 @@ import { type SidebarSelectedView = | "home" | "channel" + | "chats" | "agents" | "workflows" | "pulse" @@ -28,6 +36,7 @@ type AppSidebarPinnedHeaderProps = { onOpenDm: (input: { pubkeys: string[] }) => Promise; onOpenSearchResult: (hit: SearchHit) => void; onSelectAgents: () => void; + onSelectChats: () => void; onSelectChannel: (channelId: string) => void; onSelectHome: () => void; onSelectProjects: () => void; @@ -48,6 +57,7 @@ export function AppSidebarPinnedHeader({ onOpenDm, onOpenSearchResult, onSelectAgents, + onSelectChats, onSelectChannel, onSelectHome, onSelectProjects, @@ -96,6 +106,18 @@ export function AppSidebarPinnedHeader({ ) : null} + + + + Chats + + { + const pointerCollisions = pointerWithin(args); + if (pointerCollisions.length > 0) { + return pointerCollisions; + } + // Keyboard path: only sortable siblings are valid targets — the + // channel-assignment drop zones overlap the section rows and would + // otherwise swallow every keyboard collision. + return closestCenter({ + ...args, + droppableContainers: args.droppableContainers.filter( + (container) => container.data.current?.type !== "section-drop", + ), + }); +}; import * as React from "react"; import { cn } from "@/shared/lib/cn"; @@ -61,8 +88,15 @@ export function DroppableSectionBody({ className?: string; }) { const droppableId = `section-drop:${sectionId}`; + // Sections can only be reordered, never dropped into another section — + // disable the channel-assignment zone while a section drag is active so + // it never competes for collisions (pointer or keyboard). + const { active } = useDndContext(); + const activeType = (active?.data.current as { type?: string } | undefined) + ?.type; const { setNodeRef, isOver } = useDroppable({ id: droppableId, + disabled: activeType === "section", data: { type: "section-drop", sectionId } satisfies DndSectionDropData, }); @@ -187,6 +221,12 @@ export function SidebarDndContext({ React.useState(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + // Keyboard reordering (focus a row, Space to lift, arrows to move, + // Space to drop) — also immune to pointer auto-scroll, so tests can + // reorder deterministically. + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), ); const handleDragStart = React.useCallback( @@ -244,7 +284,7 @@ export function SidebarDndContext({ return ( { const response = await invokeTauri( "send_channel_message", @@ -845,6 +850,7 @@ export async function sendChannelMessage( mentionTags: mentionTags ?? null, mentionPubkeys: mentionPubkeys ?? null, kind: kind ?? null, + clientTags: clientTags ?? null, }, ); @@ -1329,20 +1335,6 @@ export async function nip44DecryptFromSelf( return invokeTauri("nip44_decrypt_from_self", { ciphertext }); } -// ── NIP-AB device pairing ─────────────────────────────────────────────────── - -export async function startPairing(): Promise { - return invokeTauri("start_pairing"); -} - -export async function confirmPairingSas(): Promise { - await invokeTauri("confirm_pairing_sas"); -} - -export async function cancelPairing(): Promise { - await invokeTauri("cancel_pairing"); -} - export async function applyWorkspace( relayUrl: string, nsec?: string, diff --git a/desktop/src/shared/api/tauriChats.ts b/desktop/src/shared/api/tauriChats.ts new file mode 100644 index 0000000000..2661789a88 --- /dev/null +++ b/desktop/src/shared/api/tauriChats.ts @@ -0,0 +1,151 @@ +import { invokeTauri } from "@/shared/api/tauri"; +import type { + Channel, + ChannelType, + ChatMetadata, + CreateChatInput, + SendChannelMessageResult, + SendChatContextMessageInput, + UpdateChatMetadataInput, +} from "@/shared/api/types"; + +type RawChannel = { + id: string; + name: string; + channel_type: ChannelType; + visibility: "open" | "private"; + description: string; + topic: string | null; + purpose: string | null; + member_count: number; + member_pubkeys: string[]; + created_at?: string | null; + last_message_at: string | null; + archived_at: string | null; + participants: string[]; + participant_pubkeys: string[]; + is_member?: boolean; + ttl_seconds: number | null; + ttl_deadline: string | null; +}; + +type RawChatMetadata = { + channel_id: string; + author_pubkey?: string | null; + title: string | null; + default_agent_pubkey: string | null; + template_id: string | null; + project_id: string | null; + project_name: string | null; + project_path: string | null; + project_template_id: string | null; + source_channel_id: string | null; + source_event_id: string | null; + source_thread_root_id: string | null; + updated_at: number; +}; + +type RawSendChannelMessageResult = { + event_id: string; + parent_event_id: string | null; + root_event_id: string | null; + depth: number; + created_at: number; +}; + +function fromRawChannel(channel: RawChannel): Channel { + return { + id: channel.id, + name: channel.name, + channelType: channel.channel_type, + visibility: channel.visibility, + description: channel.description, + topic: channel.topic, + purpose: channel.purpose, + memberCount: channel.member_count, + memberPubkeys: channel.member_pubkeys ?? [], + createdAt: channel.created_at ?? null, + lastMessageAt: channel.last_message_at, + archivedAt: channel.archived_at, + participants: channel.participants, + participantPubkeys: channel.participant_pubkeys, + isMember: channel.is_member ?? true, + ttlSeconds: channel.ttl_seconds, + ttlDeadline: channel.ttl_deadline, + }; +} + +function fromRawChatMetadata(metadata: RawChatMetadata): ChatMetadata { + return { + channelId: metadata.channel_id, + authorPubkey: metadata.author_pubkey ?? null, + title: metadata.title, + defaultAgentPubkey: metadata.default_agent_pubkey, + templateId: metadata.template_id, + projectId: metadata.project_id, + projectName: metadata.project_name, + projectPath: metadata.project_path, + projectTemplateId: metadata.project_template_id, + sourceChannelId: metadata.source_channel_id, + sourceEventId: metadata.source_event_id, + sourceThreadRootId: metadata.source_thread_root_id, + updatedAt: metadata.updated_at, + }; +} + +export async function listChats(): Promise { + const chats = await invokeTauri("list_chats"); + return chats.map(fromRawChannel); +} + +export async function listChatMetadata(): Promise { + const metadata = await invokeTauri("list_chat_metadata"); + return metadata.map(fromRawChatMetadata); +} + +export async function createChat(input: CreateChatInput): Promise { + return fromRawChannel( + await invokeTauri("create_chat", { input }), + ); +} + +export async function getChatMetadata( + channelId: string, +): Promise { + const metadata = await invokeTauri( + "get_chat_metadata", + { channelId }, + ); + return metadata ? fromRawChatMetadata(metadata) : null; +} + +export async function updateChatMetadata( + input: UpdateChatMetadataInput, +): Promise { + const metadata = await invokeTauri("update_chat_metadata", { + input, + }); + return fromRawChatMetadata(metadata); +} + +export async function sendChatContextMessage( + input: SendChatContextMessageInput, +): Promise { + const result = await invokeTauri( + "send_chat_context_message", + { + input, + }, + ); + return { + eventId: result.event_id, + parentEventId: result.parent_event_id, + rootEventId: result.root_event_id, + depth: result.depth, + createdAt: result.created_at, + }; +} + +export async function pickChatProjectDirectory(): Promise { + return invokeTauri("pick_team_directory"); +} diff --git a/desktop/src/shared/api/tauriPairing.ts b/desktop/src/shared/api/tauriPairing.ts new file mode 100644 index 0000000000..e5a53b49a7 --- /dev/null +++ b/desktop/src/shared/api/tauriPairing.ts @@ -0,0 +1,15 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +// ── NIP-AB device pairing ─────────────────────────────────────────────────── + +export async function startPairing(): Promise { + return invokeTauri("start_pairing"); +} + +export async function confirmPairingSas(): Promise { + await invokeTauri("confirm_pairing_sas"); +} + +export async function cancelPairing(): Promise { + await invokeTauri("cancel_pairing"); +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index cc641f1934..059d58838f 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -1,4 +1,4 @@ -export type ChannelType = "stream" | "forum" | "dm"; +export type ChannelType = "stream" | "forum" | "dm" | "chat"; export type ChannelVisibility = "open" | "private"; export type ChannelRole = "owner" | "admin" | "member" | "guest" | "bot"; @@ -12,6 +12,7 @@ export type Channel = { purpose: string | null; memberCount: number; memberPubkeys: string[]; + createdAt?: string | null; lastMessageAt: string | null; archivedAt: string | null; participants: string[]; @@ -44,12 +45,63 @@ export type ChannelMember = { export type CreateChannelInput = { name: string; - channelType: Exclude; + channelType: Exclude; visibility: ChannelVisibility; description?: string; ttlSeconds?: number; }; +export type ChatSourceInput = { + channelId?: string; + eventId?: string; + threadRootId?: string; +}; + +export type CreateChatInput = { + title?: string; + defaultAgentPubkey?: string; + templateId?: string; + projectId?: string; + projectName?: string; + projectPath?: string; + projectTemplateId?: string; + source?: ChatSourceInput; +}; + +export type UpdateChatMetadataInput = { + channelId: string; + title?: string; + defaultAgentPubkey?: string; + templateId?: string; + projectId?: string; + projectName?: string; + projectPath?: string; + projectTemplateId?: string; + source?: ChatSourceInput; +}; + +export type SendChatContextMessageInput = { + channelId: string; + content: string; + source?: ChatSourceInput; +}; + +export type ChatMetadata = { + channelId: string; + authorPubkey: string | null; + title: string | null; + defaultAgentPubkey: string | null; + templateId: string | null; + projectId: string | null; + projectName: string | null; + projectPath: string | null; + projectTemplateId: string | null; + sourceChannelId: string | null; + sourceEventId: string | null; + sourceThreadRootId: string | null; + updatedAt: number; +}; + export type OpenDmInput = { pubkeys: string[]; }; @@ -323,6 +375,7 @@ export type SearchHit = { pubkey: string; channelId: string | null; channelName: string | null; + channelType?: string | null; createdAt: number; score: number; threadRootId?: string | null; diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 8b6c8dca6d..bd1d7c6152 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -62,6 +62,7 @@ export const KIND_GIT_STATUS_DRAFT = 1633; // NIP-DV: relay-signed per-viewer DM visibility snapshot (d=viewer pubkey, // h-tags = currently-hidden DM channel ids). export const KIND_DM_VISIBILITY = 30622; +export const KIND_CHAT_METADATA = 30623; // Human-visible "new content" message kinds. Used as the unread trigger set // (sidebar badges, catch-up queries) and as the Home-feed mention query. diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index 1d29b5f182..4eca538c2a 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -23,6 +23,11 @@ export type MessageDeepLinkPayload = { threadRootId: string | null; }; +export type ChatDeepLinkPayload = { + chatId: string; + title: string | null; +}; + /** * Register listeners for deep-link events emitted by the Rust backend. * @@ -30,9 +35,9 @@ export type MessageDeepLinkPayload = { * adds a workspace for the relay (deduplicating by URL) and switches * to it. Returns an unlisten function to tear down all listeners. * - * `buzz://message?…` is handled separately by `listenForMessageDeepLinks`, - * because it needs to dispatch into the router which only exists below the - * `RouterProvider` in the component tree. + * Routed app links such as `buzz://message?…` and `buzz://chat?…` are handled + * separately because they need to dispatch into the router, which only exists + * below the `RouterProvider` in the component tree. */ export function listenForDeepLinks(deps: DeepLinkDeps): Promise { return listen("deep-link-connect", (event) => { @@ -64,3 +69,11 @@ export function listenForMessageDeepLinks( onOpen(event.payload); }); } + +export function listenForChatDeepLinks( + onOpen: (payload: ChatDeepLinkPayload) => void, +): Promise { + return listen("deep-link-chat", (event) => { + onOpen(event.payload); + }); +} diff --git a/desktop/src/shared/lib/githubPullRequest.ts b/desktop/src/shared/lib/githubPullRequest.ts new file mode 100644 index 0000000000..5c5a08674e --- /dev/null +++ b/desktop/src/shared/lib/githubPullRequest.ts @@ -0,0 +1,175 @@ +import { useQuery } from "@tanstack/react-query"; + +import { invokeTauri } from "@/shared/api/tauri"; + +export type GithubPullRequestInfo = { + title: string; + /** GitHub PR state: `open` or `closed` (merged PRs report `closed`). */ + state: string; + merged: boolean; + draft: boolean; + additions: number; + deletions: number; + changedFiles: number; + /** Source branch of the PR (`head.ref`). */ + headRef: string; + /** Head commit sha — used to query check runs. */ + headSha: string; + /** Issue-level comment count. */ + comments: number; + /** Review (inline) comment count. */ + reviewComments: number; +}; + +export type GithubCheckSummary = { + total: number; + pending: number; + failed: number; + succeeded: number; + /** Individual check runs for the expanded monitor view. */ + runs: Array<{ name: string; state: "pending" | "success" | "failure" }>; +}; + +export type GithubCommentState = { + /** + * Review threads still awaiting the PR author's reply. REST can't see the + * "resolved" bit, so replying is what clears a thread from this count. + */ + openThreads: number; +}; + +export type GithubPullRequestRef = { + owner: string; + repo: string; + number: number; +}; + +/** Parse `github.com/{owner}/{repo}/pull/{number}` out of a PR link. */ +export function parseGithubPullRequestRef( + href: string, +): GithubPullRequestRef | null { + let parsed: URL; + try { + parsed = new URL(href); + } catch { + return null; + } + if (parsed.hostname.toLowerCase().replace(/^www\./, "") !== "github.com") { + return null; + } + const [owner, repo, resource, number] = parsed.pathname + .split("/") + .filter(Boolean); + if (!owner || !repo || resource !== "pull" || !/^\d+$/.test(number ?? "")) { + return null; + } + return { owner, repo, number: Number(number) }; +} + +export function githubPullRequestStatus(info: GithubPullRequestInfo) { + if (info.merged) return "merged" as const; + if (info.state === "closed") return "closed" as const; + if (info.draft) return "draft" as const; + return "open" as const; +} + +export function useGithubPullRequestQuery(ref: GithubPullRequestRef | null) { + return useQuery({ + enabled: ref !== null, + queryKey: [ + "github-pull-request", + ref?.owner ?? "", + ref?.repo ?? "", + ref?.number ?? 0, + ], + queryFn: async () => + (await invokeTauri( + "fetch_github_pull_request", + { + owner: ref?.owner ?? "", + repo: ref?.repo ?? "", + number: ref?.number ?? 0, + }, + )) ?? null, + staleTime: 60_000, + // The work panel doubles as a PR monitor — keep state and comment + // counts fresh while mounted. + refetchInterval: 60_000, + retry: 1, + }); +} + +export function useGithubCheckSummaryQuery( + ref: GithubPullRequestRef | null, + sha: string | null | undefined, +) { + return useQuery({ + enabled: ref !== null && Boolean(sha), + queryKey: [ + "github-check-summary", + ref?.owner ?? "", + ref?.repo ?? "", + sha ?? "", + ], + queryFn: async () => + (await invokeTauri( + "fetch_github_check_summary", + { + owner: ref?.owner ?? "", + repo: ref?.repo ?? "", + sha: sha ?? "", + }, + )) ?? null, + staleTime: 30_000, + // CI flips fast while runs execute; poll while the panel is mounted. + refetchInterval: 45_000, + retry: 1, + }); +} + +/** + * Discover the PR for a branch via the project's git remote — the fallback + * when no PR link was ever posted in the chat. Polls slowly so a PR opened + * mid-conversation surfaces on its own. + */ +export function useGithubPrForBranchQuery( + projectPath: string | null | undefined, + branch: string | null | undefined, +) { + return useQuery({ + enabled: Boolean(projectPath) && Boolean(branch), + queryKey: ["github-pr-for-branch", projectPath ?? "", branch ?? ""], + queryFn: async () => + (await invokeTauri("find_github_pr_for_branch", { + projectPath: projectPath ?? "", + branch: branch ?? "", + })) ?? null, + staleTime: 60_000, + refetchInterval: 90_000, + retry: 1, + }); +} + +export function useGithubCommentStateQuery(ref: GithubPullRequestRef | null) { + return useQuery({ + enabled: ref !== null, + queryKey: [ + "github-pr-comment-state", + ref?.owner ?? "", + ref?.repo ?? "", + ref?.number ?? 0, + ], + queryFn: async () => + (await invokeTauri( + "fetch_github_pr_comment_state", + { + owner: ref?.owner ?? "", + repo: ref?.repo ?? "", + number: ref?.number ?? 0, + }, + )) ?? null, + staleTime: 30_000, + refetchInterval: 60_000, + retry: 1, + }); +} diff --git a/desktop/src/shared/styles/globals/animations.css b/desktop/src/shared/styles/globals/animations.css index 3cc935ec40..2d11f0640a 100644 --- a/desktop/src/shared/styles/globals/animations.css +++ b/desktop/src/shared/styles/globals/animations.css @@ -123,22 +123,55 @@ transform-origin: center; } -.buzz-shimmer { +/* Entrance for newly-arrived chat rows (messages, activity markers). Uses + transform/opacity only so the row's measured height is final on mount and + the message scroller's anchoring/auto-follow math is undisturbed — this is + the shadcn message-scroller "slide-up" preset. */ +.buzz-message-entrance { + animation: buzz-message-entrance 260ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@keyframes buzz-message-entrance { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .buzz-message-entrance { + animation: none; + } +} + +.buzz-shimmer, +.shimmer { --buzz-shimmer-duration: 2000ms; --buzz-shimmer-band: 400%; - --buzz-shimmer-highlight: color-mix( - in srgb, - hsl(var(--foreground)) 82%, - hsl(var(--muted-foreground)) 18% - ); + --buzz-shimmer-highlight: hsl(var(--foreground)); - color: hsl(var(--muted-foreground)); + /* Base text is dimmed while loading so the sweeping highlight band + (full foreground) reads clearly against it in both themes. */ + color: hsl(var(--muted-foreground) / 0.55); display: inline-block; position: relative; } -.buzz-shimmer::before { +.buzz-shimmer::before, +.shimmer::before { animation: buzz-shimmer var(--buzz-shimmer-duration) linear infinite; + /* Truncate exactly like the base text: the overlay repeats the FULL + data-shimmer-text, and without its own ellipsis it paints the clipped + characters right over the base ellipsis on truncating labels (sidebar + titles). Same font + box → the ellipsis lands in the same spot. */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; background-clip: text; background-image: linear-gradient( 90deg, @@ -159,18 +192,97 @@ -webkit-text-fill-color: transparent; } +/* One right-to-left sweep per cycle, phase-shifted to start mid-sweep so a + freshly mounted row (the Working marker re-mounts between tool executions) + shows the band immediately instead of waiting out the transparent lead-in. + The 50%→50.01% jump lands while the band is outside the text, so it is + invisible. Keyframes instead of a negative animation-delay — WKWebView + handles calc() delays on pseudo-elements unreliably. */ @keyframes buzz-shimmer { 0% { + background-position: 50% 0; + } + + 50% { + background-position: 0% 0; + } + + 50.01% { background-position: 100% 0; } 100% { - background-position: 0% 0; + background-position: 50% 0; + } +} + +/* Accent variant: keeps the text at its normal color and sweeps a + primary-colored band across it, for places (sidebar titles) where the + default dimmed-base treatment reads as a de-emphasized row. + + The band must contrast with the TEXT it sweeps (full foreground — this + variant never dims), so the primary hue is pulled toward the background: + lighter in light mode (against near-black text), darker in dark mode + (against near-white text). Raw hsl(var(--primary)) sat too close to the + text's lightness in both themes and the sweep barely read. The narrower + band (300% vs 400%) also makes the highlight cover more of the visible + text per pass. + + Light: primary washed toward white — a pale lavender band over near-black + text. Dark: primary deepened toward black in oklch (which keeps the hue + saturated instead of graying toward the background) — a rich mid-purple + band over near-white text. */ +.buzz-shimmer-accent { + /* The band hugs the BACKGROUND color (a whisper of primary keeps the + hue): glyphs visibly wash out toward the background as it passes, + which is the strongest possible contrast against full-color text. */ + --buzz-shimmer-highlight: color-mix( + in oklab, + hsl(var(--primary)) 8%, + hsl(var(--background)) + ); + --buzz-shimmer-band: 300%; + color: inherit; +} + +@media (prefers-reduced-motion: reduce) { + .buzz-shimmer, + .shimmer { + color: hsl(var(--muted-foreground)); + } + + .buzz-shimmer-accent { + color: inherit; + } + + .buzz-shimmer::before, + .shimmer::before { + animation: none; + content: none; + } +} + +/* Work-panel content arriving (the agent posted a PR): a gentle pop — + slight scale + fade, matching the message entrance feel. */ +@keyframes buzz-work-card-in { + from { + opacity: 0; + transform: scale(0.96); } + + to { + opacity: 1; + transform: scale(1); + } +} + +.buzz-work-card-in { + animation: buzz-work-card-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both; + transform-origin: top center; } @media (prefers-reduced-motion: reduce) { - .buzz-shimmer::before { + .buzz-work-card-in { animation: none; } } diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index fdc25d2873..10b75a4a99 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -21,6 +21,14 @@ --buzz-link-preview-icon-color: hsl(var(--foreground)); } +.message-markdown [data-chat-link-card] { + box-sizing: border-box; +} + +.message-markdown p:has(> [data-chat-link-card]:only-child) { + max-width: 100%; +} + .dark .message-markdown [data-link-preview] { --buzz-link-preview-icon-bg: #24292f; } @@ -100,6 +108,34 @@ color 150ms ease-out; } +/* Link chips inside the user's own message bubble: the standalone card + treatment (opaque muted fill + provider-colored icon tile) reads as a + gray slab against the primary bubble. Tint everything from currentColor + instead so the chip reads as part of the bubble. */ +.buzz-own-bubble-links [data-link-preview] { + background-color: color-mix(in oklab, currentColor 9%, transparent); + border-color: color-mix(in oklab, currentColor 22%, transparent); +} + +.buzz-own-bubble-links [data-link-preview]:hover { + background-color: color-mix(in oklab, currentColor 15%, transparent); + border-color: color-mix(in oklab, currentColor 32%, transparent); +} + +.buzz-own-bubble-links [data-link-preview] .link-preview-media { + background-color: color-mix(in oklab, currentColor 13%, transparent); + color: currentColor; +} + +/* Mention chips get the same treatment: their standalone palette is + primary-on-primary-tint, which disappears entirely inside the + primary-colored own bubble (names rendered as blank gaps). currentColor + derivation keeps them legible on any bubble. */ +.buzz-own-bubble-links .message-markdown .mention-chip { + background: color-mix(in oklab, currentColor 16%, transparent); + color: inherit; +} + .search-result-row[aria-selected="true"] .message-markdown .mention-chip.search-channel-chip { diff --git a/desktop/src/shared/ui/animated-title-text.tsx b/desktop/src/shared/ui/animated-title-text.tsx new file mode 100644 index 0000000000..590aae02d8 --- /dev/null +++ b/desktop/src/shared/ui/animated-title-text.tsx @@ -0,0 +1,165 @@ +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; + +// Mirrors the search placeholder swap (SearchPromptPlaceholder): the old text +// rises out with a blur while the new text rises in, characters staggered, +// and the container width glides between the two measured text widths. +const TITLE_SWAP_EASE = [0.22, 1, 0.36, 1] as const; +const TITLE_SWAP_EXIT_EASE = [0.64, 0, 0.78, 0] as const; +const TITLE_SWAP_ENTER_DURATION_SECONDS = 0.54; +const TITLE_SWAP_EXIT_DURATION_SECONDS = 0.32; +const TITLE_SWAP_ENTER_STAGGER_SECONDS = 0.014; +const TITLE_SWAP_EXIT_STAGGER_SECONDS = 0.008; +const TITLE_SWAP_Y_OFFSET = "0.5rem"; +const TITLE_SWAP_NEGATIVE_Y_OFFSET = "-0.5rem"; +const TITLE_SWAP_BLUR = "0.25rem"; + +const titleSwapPhraseVariants = { + animate: { + transition: { staggerChildren: TITLE_SWAP_ENTER_STAGGER_SECONDS }, + }, + exit: { + transition: { staggerChildren: TITLE_SWAP_EXIT_STAGGER_SECONDS }, + }, + initial: {}, +}; + +const titleSwapCharacterVariants = { + animate: { + filter: "blur(0)", + opacity: 1, + transition: { + duration: TITLE_SWAP_ENTER_DURATION_SECONDS, + ease: TITLE_SWAP_EASE, + }, + y: 0, + }, + exit: { + filter: `blur(${TITLE_SWAP_BLUR})`, + opacity: 0, + transition: { + duration: TITLE_SWAP_EXIT_DURATION_SECONDS, + ease: TITLE_SWAP_EXIT_EASE, + }, + y: TITLE_SWAP_NEGATIVE_Y_OFFSET, + }, + initial: { + filter: `blur(${TITLE_SWAP_BLUR})`, + opacity: 0, + y: TITLE_SWAP_Y_OFFSET, + }, +}; + +function getTitleCharacters(value: string) { + const characterCounts = new Map(); + return [...value].map((character) => { + const occurrence = characterCounts.get(character) ?? 0; + characterCounts.set(character, occurrence + 1); + return { character, key: `${character}-${occurrence}` }; + }); +} + +/** + * Text that swaps with the search-placeholder character animation whenever + * `text` changes. The first render never animates, so mounting (opening a + * view) shows the text immediately — only in-place renames animate. + * + * The wrapper width is measured explicitly from an invisible copy of the + * text (same approach as SearchPromptPlaceholder). Shrink-to-fit sizing + * derived from the absolutely positioned animation layer is circular and + * collapses the box. + */ +export function AnimatedTitleText({ + className, + text, +}: { + className?: string; + text: string; +}) { + const shouldReduceMotion = useReducedMotion(); + const measureRef = React.useRef(null); + const pendingWidthRef = React.useRef(null); + const [width, setWidth] = React.useState(null); + + React.useLayoutEffect(() => { + if (shouldReduceMotion || text.length === 0) { + return; + } + const measured = measureRef.current?.getBoundingClientRect().width; + if (typeof measured !== "number" || !Number.isFinite(measured)) { + return; + } + if (width === null) { + // First measurement: apply immediately (no swap in flight). + setWidth(measured); + } else { + // Hold the new width until the old text finishes exiting so the box + // resizes together with the incoming text. + pendingWidthRef.current = measured; + } + }, [shouldReduceMotion, text, width]); + + const handleExitComplete = React.useCallback(() => { + const nextWidth = pendingWidthRef.current; + if (nextWidth === null) { + return; + } + pendingWidthRef.current = null; + setWidth(nextWidth); + }, []); + + if (shouldReduceMotion) { + return {text}; + } + + const characters = getTitleCharacters(text); + + return ( + + {text} + {/* Invisible copy at natural width — the measurement source that keeps + the wrapper sized while the visible layer animates absolutely. */} + + + + + + ); +} diff --git a/desktop/src/shared/ui/bubble.tsx b/desktop/src/shared/ui/bubble.tsx new file mode 100644 index 0000000000..650d9f46bf --- /dev/null +++ b/desktop/src/shared/ui/bubble.tsx @@ -0,0 +1,43 @@ +import type * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; + +import { cn } from "@/shared/lib/cn"; + +type BubbleProps = React.ComponentProps<"div"> & { + asChild?: boolean; + side?: "left" | "right"; + variant?: "default" | "muted" | "outline" | "ghost"; +}; + +function Bubble({ + asChild, + className, + side = "left", + variant = "default", + ...props +}: BubbleProps) { + const Comp = asChild ? Slot : "div"; + + return ( + + ); +} + +export { Bubble }; diff --git a/desktop/src/shared/ui/link-preview-attachment.tsx b/desktop/src/shared/ui/link-preview-attachment.tsx index 5d6b80767a..c9a90e8727 100644 --- a/desktop/src/shared/ui/link-preview-attachment.tsx +++ b/desktop/src/shared/ui/link-preview-attachment.tsx @@ -1,7 +1,18 @@ -import { ExternalLink } from "lucide-react"; +import { + ExternalLink, + GitMerge, + GitPullRequest, + GitPullRequestClosed, + GitPullRequestDraft, +} from "lucide-react"; import type { SupportedLinkPreview } from "@/shared/lib/linkPreview"; import { cn } from "@/shared/lib/cn"; +import { + githubPullRequestStatus, + parseGithubPullRequestRef, + useGithubPullRequestQuery, +} from "@/shared/lib/githubPullRequest"; import { Attachment, AttachmentActions, @@ -109,6 +120,200 @@ function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) { } } +const PR_STATUS_META = { + open: { + icon: GitPullRequest, + label: "Open", + className: "text-[color:var(--status-added)]", + }, + draft: { + icon: GitPullRequestDraft, + label: "Draft", + className: "text-muted-foreground", + }, + merged: { + icon: GitMerge, + label: "Merged", + className: "text-violet-500 dark:text-violet-400", + }, + closed: { + icon: GitPullRequestClosed, + label: "Closed", + className: "text-[color:var(--status-deleted)]", + }, +} as const; + +/** + * Rich GitHub PR card: live state, diff stats, and files changed from the + * GitHub API. Falls back to the compact static card while loading or when + * the fetch fails (rate limit, private repo without a token, offline). + */ +export function GithubPullRequestCard({ + className, + preview, +}: { + className?: string; + preview: SupportedLinkPreview; +}) { + const ref = parseGithubPullRequestRef(preview.href); + const query = useGithubPullRequestQuery(ref); + const info = query.data ?? null; + + if (!ref || !info) { + return ; + } + + const status = githubPullRequestStatus(info); + const meta = PR_STATUS_META[status]; + const StatusIcon = meta.icon; + + return ( + + + + + +
+ + + {ref.owner}/{ref.repo} + + + #{ref.number} +
+ {info.title || preview.title} +
+ + {meta.label} + + + +{info.additions.toLocaleString()} + + + −{info.deletions.toLocaleString()} + + + {info.changedFiles.toLocaleString()}{" "} + {info.changedFiles === 1 ? "file" : "files"} changed + +
+
+ + + + + + Open GitHub PR {ref.owner}/{ref.repo} #{ref.number} + + + +
+ ); +} + +/** + * Prominent PR card for agent-authored messages — when the agent reports a + * pull request it created, the card reads as a piece of delivered work + * (banner layout, status pill, full-width diff stats) rather than a pasted + * link. Falls back to the standard rich card without live data. + */ +export function AgentPullRequestCard({ + className, + preview, +}: { + className?: string; + preview: SupportedLinkPreview; +}) { + const ref = parseGithubPullRequestRef(preview.href); + const query = useGithubPullRequestQuery(ref); + const info = query.data ?? null; + + if (!ref || !info) { + return ; + } + + const status = githubPullRequestStatus(info); + const meta = PR_STATUS_META[status]; + const StatusIcon = meta.icon; + + return ( + +
+ + + {ref.owner}/{ref.repo} + + + Pull request #{ref.number} + + + {meta.label} + +
+
+ {info.title || preview.title} +
+
+ + +{info.additions.toLocaleString()} + + + −{info.deletions.toLocaleString()} + + + {info.changedFiles.toLocaleString()}{" "} + {info.changedFiles === 1 ? "file" : "files"} changed + +
+ + + + Open GitHub PR {ref.owner}/{ref.repo} #{ref.number} + + + +
+ ); +} + export function LinkPreviewAttachment({ className, preview, diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 8275c1b3c1..d4894b3621 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -14,11 +14,10 @@ import remarkGfm from "remark-gfm"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { - parseMessageLink, - resolveMessageLinkRenderTarget, - type ParsedMessageLink, -} from "@/features/messages/lib/messageLink"; +import type { ParsedChatLink } from "@/features/chats/lib/chatLink"; +import { resolveChatOpenDestination } from "@/features/chats/lib/chatOpenDestination"; +import remarkChatLinks from "@/features/chats/lib/remarkChatLinks"; +import type { ParsedMessageLink } from "@/features/messages/lib/messageLink"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { invokeTauri } from "@/shared/api/tauri"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; @@ -38,7 +37,10 @@ import remarkSpoilers from "@/shared/lib/remarkSpoilers"; import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks"; import { AttachmentGroup } from "@/shared/ui/attachment"; import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment"; -import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment"; +import { + AgentPullRequestCard, + LinkPreviewAttachment, +} from "@/shared/ui/link-preview-attachment"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; import { computeConfigNudge, @@ -73,7 +75,11 @@ import { FileCard } from "./markdown/FileCard"; import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover"; import { MarkdownInput } from "./markdown/MarkdownInput"; import { MarkdownTable } from "./markdown/MarkdownTable"; -import { MessageLinkPill } from "./markdown/MessageLinkPill"; +import { + MarkdownChatLinkNode, + MarkdownMessageLinkNode, + renderMarkdownAppLink, +} from "./markdown/AppLink"; import { resolveFileCard } from "./markdownFileCard"; import type { ImetaEntry, @@ -1556,7 +1562,7 @@ function createMarkdownComponents( ), a: ({ children, href, ...props }) => { - const { imetaByUrl, onOpenMessageLink } = runtimeRef.current; + const { imetaByUrl } = runtimeRef.current; if (!interactive) { return {children}; } @@ -1592,37 +1598,15 @@ function createMarkdownComponents( // in-app instead of opening the URL in the OS browser. http(s) links // continue to use the existing target="_blank" behavior. if (href) { - const messageLinkTarget = resolveMessageLinkRenderTarget({ + const appLink = renderMarkdownAppLink({ + children, href, + interactive, label, + props, + runtimeRef, }); - if (messageLinkTarget.kind !== "none") { - if (messageLinkTarget.kind === "pill") { - return ( - - ); - } - - return ( - { - event.preventDefault(); - onOpenMessageLink(messageLinkTarget.link); - }} - > - {children} - - ); - } + if (appLink) return appLink; // Malformed message deep link — fall through to the default // anchor (renders as a normal external link). } @@ -1884,6 +1868,7 @@ function createMarkdownComponents( const channel = channels.find( (c) => c.channelType !== "dm" && + c.channelType !== "chat" && c.name.toLowerCase() === channelName.toLowerCase(), ); @@ -1914,29 +1899,27 @@ function createMarkdownComponents( ); }, "message-link": ({ children }: { children?: React.ReactNode }) => { - const { channels, onOpenMessageLink } = runtimeRef.current; - const href = String(children ?? ""); - const parsed = parseMessageLink(href); - if (!parsed.ok) { - // Malformed `buzz://message?…` — render the raw URL as plain text - // rather than a misleading clickable pill. - return {href}; - } - return ( - + runtimeRef={runtimeRef} + > + {children} + + ); + }, + "chat-link": ({ children }: { children?: React.ReactNode }) => { + return ( + + {children} + ); }, } as Components; } function MarkdownInner({ + agentAuthored = false, channelNames, className, configNudgeAuthorPubkey, @@ -1953,7 +1936,7 @@ function MarkdownInner({ }: MarkdownProps) { const { channels: rawChannels } = useChannelNavigation(); const channels = useStableArray(rawChannels); - const { goChannel } = useAppNavigation(); + const { goChannel, goChat } = useAppNavigation(); const onOpenChannel = React.useCallback( (channelId: string) => { void goChannel(channelId); @@ -1962,19 +1945,39 @@ function MarkdownInner({ ); const onOpenMessageLink = React.useCallback( (link: ParsedMessageLink) => { - // Always route through `goChannel` with `messageId` set: the channel - // route already handles scroll-into-view + highlight via + // Message links to chat-backed channels open in Chats on builds that + // understand chat metadata. Other message links route through + // `goChannel` with `messageId` set: the channel route already handles + // scroll-into-view + highlight via // `useAnchoredScroll` + `getEventById` backfill, and works for // both stream-message replies and forum threads. Detecting "the thread // root is a forum post" up front would require an event lookup we don't // currently have synchronously; the brief explicitly allows skipping // that detection and falling through. - void goChannel(link.channelId, { - messageId: link.messageId, - threadRootId: link.threadRootId, + void resolveChatOpenDestination(link.channelId).then((destination) => { + if (destination.kind === "chat") { + void goChat(destination.chatId); + return; + } + void goChannel(destination.channelId, { + messageId: link.messageId, + threadRootId: link.threadRootId, + }); }); }, - [goChannel], + [goChannel, goChat], + ); + const onOpenChatLink = React.useCallback( + (link: ParsedChatLink) => { + void resolveChatOpenDestination(link.chatId).then((destination) => { + if (destination.kind === "chat") { + void goChat(destination.chatId); + } else { + void goChannel(destination.channelId); + } + }); + }, + [goChannel, goChat], ); const linkPreviews = React.useMemo( () => (interactive ? extractSupportedLinkPreviews(content) : []), @@ -1989,6 +1992,7 @@ function MarkdownInner({ channels, imetaByUrl, mentionPubkeysByName, + onOpenChatLink, onOpenChannel, onOpenMessageLink, }); @@ -2004,6 +2008,7 @@ function MarkdownInner({ remarkGfm, remarkBreaks, remarkSpoilers, + remarkChatLinks, remarkMessageLinks, [remarkMentions, { mentionNames }], [remarkChannelLinks, { channelNames }], @@ -2086,9 +2091,15 @@ function MarkdownInner({ className="max-w-full flex-wrap overflow-visible pb-0" data-link-preview-list="" > - {resolvedLinkPreviews.map((preview) => ( - - ))} + {resolvedLinkPreviews.map((preview) => + preview.kind === "github-pull-request" && agentAuthored ? ( + + ) : ( + // Plain dropped links keep the compact static chip; the + // live rich card is reserved for the chat work drawer. + + ), + )} ) : null} @@ -2100,6 +2111,7 @@ export const Markdown = React.memo( MarkdownInner, (prev, next) => prev.content === next.content && + prev.agentAuthored === next.agentAuthored && prev.className === next.className && prev.customEmoji === next.customEmoji && prev.interactive === next.interactive && diff --git a/desktop/src/shared/ui/markdown/AppLink.tsx b/desktop/src/shared/ui/markdown/AppLink.tsx new file mode 100644 index 0000000000..d7682a37a3 --- /dev/null +++ b/desktop/src/shared/ui/markdown/AppLink.tsx @@ -0,0 +1,151 @@ +import type * as React from "react"; + +import { + parseChatLink, + resolveChatLinkRenderTarget, +} from "@/features/chats/lib/chatLink"; +import { + parseMessageLink, + resolveMessageLinkRenderTarget, +} from "@/features/messages/lib/messageLink"; + +import { ChatLinkCard } from "./ChatLinkCard"; +import { MessageLinkPill } from "./MessageLinkPill"; +import type { MarkdownRuntime } from "./types"; + +const APP_LINK_CLASS = + "font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80 cursor-pointer"; + +type RuntimeRef = React.RefObject; + +type RenderMarkdownAppLinkInput = { + children: React.ReactNode; + href: string; + interactive: boolean; + label: string; + props: React.ComponentProps<"a">; + runtimeRef: RuntimeRef; +}; + +export function renderMarkdownAppLink({ + children, + href, + interactive, + label, + props, + runtimeRef, +}: RenderMarkdownAppLinkInput) { + const { channels, onOpenChatLink, onOpenMessageLink } = runtimeRef.current; + const chatLinkTarget = resolveChatLinkRenderTarget({ href, label }); + if (chatLinkTarget.kind !== "none") { + if (chatLinkTarget.kind === "card") { + return ( + + ); + } + + return ( + { + event.preventDefault(); + onOpenChatLink(chatLinkTarget.link); + }} + > + {children} + + ); + } + + const messageLinkTarget = resolveMessageLinkRenderTarget({ href, label }); + if (messageLinkTarget.kind === "none") { + return null; + } + if (messageLinkTarget.kind === "pill") { + return ( + + ); + } + + return ( + { + event.preventDefault(); + onOpenMessageLink(messageLinkTarget.link); + }} + > + {children} + + ); +} + +export function MarkdownChatLinkNode({ + children, + interactive, + runtimeRef, +}: { + children?: React.ReactNode; + interactive: boolean; + runtimeRef: RuntimeRef; +}) { + const { channels, onOpenChatLink } = runtimeRef.current; + const href = String(children ?? ""); + const parsed = parseChatLink(href); + if (!parsed.ok) { + return {href}; + } + + return ( + + ); +} + +export function MarkdownMessageLinkNode({ + children, + interactive, + runtimeRef, +}: { + children?: React.ReactNode; + interactive: boolean; + runtimeRef: RuntimeRef; +}) { + const { channels, onOpenMessageLink } = runtimeRef.current; + const href = String(children ?? ""); + const parsed = parseMessageLink(href); + if (!parsed.ok) { + return {href}; + } + + return ( + + ); +} diff --git a/desktop/src/shared/ui/markdown/ChatLinkCard.tsx b/desktop/src/shared/ui/markdown/ChatLinkCard.tsx new file mode 100644 index 0000000000..badb6dd94a --- /dev/null +++ b/desktop/src/shared/ui/markdown/ChatLinkCard.tsx @@ -0,0 +1,65 @@ +import { MessageCircle, MoveUpRight } from "lucide-react"; + +import type { ChatLinkCardProps } from "./types"; +import { cn } from "@/shared/lib/cn"; + +export function ChatLinkCard({ + channels, + href, + interactive, + link, + onOpenChatLink, +}: ChatLinkCardProps) { + const channel = channels.find((c) => c.id === link.chatId); + const title = link.title || channel?.name || "Side conversation"; + const shortId = link.chatId.slice(0, 8); + + const content = ( + <> + + + + + + {title} + + + Open shared chat · {shortId} + + + {interactive ? ( + + + + ) : null} + + ); + + if (!interactive) { + return ( + + {content} + + ); + } + + return ( + + ); +} diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index d5ae941a43..86fea5b727 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -1,3 +1,4 @@ +import type { ParsedChatLink } from "@/features/chats/lib/chatLink"; import type { ParsedMessageLink } from "@/features/messages/lib/messageLink"; import type { Channel } from "@/shared/api/types"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; @@ -23,16 +24,30 @@ export type MessageLinkPillProps = { onOpenMessageLink: (link: ParsedMessageLink) => void; }; +export type ChatLinkCardProps = { + channels: Channel[]; + href: string; + interactive: boolean; + link: ParsedChatLink; + onOpenChatLink: (link: ParsedChatLink) => void; +}; + export type MarkdownRuntime = { agentMentionPubkeysByName?: Record; channels: Channel[]; imetaByUrl?: ImetaLookup; mentionPubkeysByName?: Record; + onOpenChatLink: (link: ParsedChatLink) => void; onOpenChannel: (channelId: string) => void; onOpenMessageLink: (link: ParsedMessageLink) => void; }; export type MarkdownProps = { + /** + * Message author is an agent — link previews it authored (e.g. PR links) + * render their richer agent-work variants. + */ + agentAuthored?: boolean; channelNames?: string[]; className?: string; content: string; diff --git a/desktop/src/shared/ui/markdown/utils.ts b/desktop/src/shared/ui/markdown/utils.ts index db9629921e..60083d81c4 100644 --- a/desktop/src/shared/ui/markdown/utils.ts +++ b/desktop/src/shared/ui/markdown/utils.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { defaultUrlTransform } from "react-markdown"; +import { isChatLink } from "@/features/chats/lib/chatLink"; import { isMessageLink } from "@/features/messages/lib/messageLink"; export function useStableArray(arr: T[]): T[] { @@ -166,13 +167,13 @@ export function isInsideHiddenSpoiler(element: Element): boolean { } /** - * `urlTransform` for `` that preserves `buzz://message?…` - * links. The default transform strips unknown schemes (returns `""`) before - * the `a` component override can see them, which would break copy → paste → - * click end-to-end. Everything else delegates to `defaultUrlTransform`. + * `urlTransform` for `` that preserves Buzz app links. The + * default transform strips unknown schemes (returns `""`) before the component + * overrides can see them, which would break copy → paste → click end-to-end. + * Everything else delegates to `defaultUrlTransform`. */ export function messageLinkUrlTransform(value: string, key: string): string { - if (key === "href" && isMessageLink(value)) { + if (key === "href" && (isMessageLink(value) || isChatLink(value))) { return value; } return defaultUrlTransform(value); diff --git a/desktop/src/shared/ui/marker.tsx b/desktop/src/shared/ui/marker.tsx new file mode 100644 index 0000000000..4dcfb145c6 --- /dev/null +++ b/desktop/src/shared/ui/marker.tsx @@ -0,0 +1,69 @@ +import type * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/shared/lib/cn"; + +const markerVariants = cva( + "group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground", + { + variants: { + variant: { + default: "", + separator: + "before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border", + border: "border-b border-border pb-2", + }, + }, + }, +); + +function Marker({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"div"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "div"; + + return ( + + ); +} + +function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) { + return ( +