From fb548c6373a0622206d3c81406b7db394f5fb731 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 2 Jul 2026 16:17:52 +0100 Subject: [PATCH 01/68] Add Chats mode --- crates/buzz-acp/src/base_prompt.md | 3 +- crates/buzz-acp/src/config.rs | 34 + crates/buzz-acp/src/lib.rs | 48 +- crates/buzz-acp/src/pool.rs | 350 ++++++- crates/buzz-acp/src/queue.rs | 171 +++- crates/buzz-acp/src/relay.rs | 9 +- crates/buzz-core/src/channel.rs | 23 + crates/buzz-core/src/kind.rs | 8 + crates/buzz-db/src/channel.rs | 7 +- crates/buzz-db/src/migration.rs | 20 +- crates/buzz-relay/src/handlers/ingest.rs | 23 +- .../buzz-relay/src/handlers/side_effects.rs | 7 +- desktop/package.json | 1 + desktop/src-tauri/src/commands/channels.rs | 21 + desktop/src-tauri/src/commands/chats.rs | 700 +++++++++++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/deep_link.rs | 52 +- desktop/src-tauri/src/events.rs | 2 - desktop/src-tauri/src/lib.rs | 6 + desktop/src-tauri/src/models.rs | 2 + desktop/src-tauri/src/nostr_convert.rs | 1 + desktop/src/app/AppShell.helpers.ts | 8 + desktop/src/app/AppShell.tsx | 25 +- desktop/src/app/AppShellContext.tsx | 4 + .../resolveSearchHitDestination.test.mjs | 19 + .../navigation/resolveSearchHitDestination.ts | 13 + .../src/app/navigation/useAppNavigation.ts | 49 +- desktop/src/app/routeTree.gen.ts | 42 + desktop/src/app/routes.ts | 2 + desktop/src/app/routes/chats.$chatId.tsx | 21 + desktop/src/app/routes/chats.tsx | 42 + .../agents/ui/AddAgentToChannelDialog.tsx | 5 +- .../agents/ui/AddTeamToChannelDialog.tsx | 5 +- .../channel-templates/useApplyTemplate.ts | 21 +- desktop/src/features/channels/hooks.ts | 1 + .../channels/ui/ChannelBrowserDialog.tsx | 1 + .../features/channels/ui/ChannelCanvas.tsx | 11 +- .../src/features/channels/ui/ChannelPane.tsx | 2 + .../features/channels/ui/ChannelPane.types.ts | 1 + .../features/channels/ui/ChannelScreen.tsx | 10 + .../channels/ui/useChannelUnreadState.ts | 7 +- .../channels/ui/useSideConversation.ts | 133 +++ desktop/src/features/chat/ui/ChatHeader.tsx | 28 +- desktop/src/features/chats/hooks.ts | 205 ++++ .../features/chats/lib/chatActivity.test.mjs | 221 +++++ .../src/features/chats/lib/chatActivity.ts | 256 +++++ .../src/features/chats/lib/chatLink.test.mjs | 158 +++ desktop/src/features/chats/lib/chatLink.ts | 136 +++ .../features/chats/lib/chatProjectStorage.ts | 131 +++ .../src/features/chats/lib/chatProjects.ts | 64 ++ .../src/features/chats/lib/chatSetup.test.mjs | 66 ++ desktop/src/features/chats/lib/chatSetup.ts | 109 ++ .../src/features/chats/lib/remarkChatLinks.ts | 42 + .../chats/ui/ChatActivityMarkerRow.tsx | 193 ++++ .../chats/ui/ChatActivityTranscript.tsx | 935 ++++++++++++++++++ .../chats/ui/ChatConversationRows.tsx | 225 +++++ desktop/src/features/chats/ui/ChatDetail.tsx | 447 +++++++++ .../features/chats/ui/ChatHeaderActions.tsx | 454 +++++++++ .../src/features/chats/ui/ChatListItem.tsx | 111 +++ .../chats/ui/ChatListSectionHeader.tsx | 33 + .../features/chats/ui/ChatListSkeleton.tsx | 53 + .../features/chats/ui/ChatProjectDialog.tsx | 218 ++++ desktop/src/features/chats/ui/ChatsScreen.tsx | 790 +++++++++++++++ .../src/features/chats/ui/QuickStartChat.tsx | 518 ++++++++++ .../features/chats/ui/chatActivityIcons.tsx | 97 ++ .../chats/ui/chatActivityText.test.mjs | 181 ++++ .../src/features/chats/ui/chatActivityText.ts | 168 ++++ .../features/forum/ui/ForumThreadPanel.tsx | 5 +- .../messages/lib/mentionCandidates.ts | 81 ++ .../features/messages/lib/useChannelLinks.ts | 9 +- .../src/features/messages/lib/useMentions.ts | 151 ++- .../features/messages/ui/MessageActionBar.tsx | 19 + .../features/messages/ui/MessageComposer.tsx | 96 +- .../messages/ui/MessageComposerToolbar.tsx | 140 ++- .../src/features/messages/ui/MessageRow.tsx | 8 +- .../features/messages/ui/MessageTimeline.tsx | 3 + .../messages/ui/TimelineMessageList.tsx | 8 + .../messages/ui/useMentionSendFlow.ts | 105 +- .../src/features/onboarding/welcomeGuide.ts | 15 + .../src/features/sidebar/ui/AppSidebar.tsx | 4 + .../sidebar/ui/AppSidebarPinnedHeader.tsx | 24 +- desktop/src/shared/api/tauri.ts | 4 + desktop/src/shared/api/tauriChats.ts | 151 +++ desktop/src/shared/api/types.ts | 57 +- desktop/src/shared/constants/kinds.ts | 1 + desktop/src/shared/deep-link.ts | 19 +- .../src/shared/styles/globals/animations.css | 24 +- .../src/shared/styles/globals/markdown.css | 8 + desktop/src/shared/ui/bubble.tsx | 43 + desktop/src/shared/ui/markdown.tsx | 88 +- desktop/src/shared/ui/markdown/AppLink.tsx | 151 +++ .../src/shared/ui/markdown/ChatLinkCard.tsx | 65 ++ desktop/src/shared/ui/markdown/types.ts | 10 + desktop/src/shared/ui/markdown/utils.ts | 11 +- desktop/src/shared/ui/marker.tsx | 69 ++ desktop/src/shared/ui/message-scroller.tsx | 123 +++ desktop/src/shared/ui/message.tsx | 67 ++ desktop/src/shared/useMessageDeepLinks.ts | 28 +- desktop/src/testing/e2eBridge.ts | 290 +++++- migrations/0006_add_chat_channel_type.sql | 1 + pnpm-lock.yaml | 19 + 101 files changed, 9308 insertions(+), 340 deletions(-) create mode 100644 desktop/src-tauri/src/commands/chats.rs create mode 100644 desktop/src/app/navigation/resolveSearchHitDestination.test.mjs create mode 100644 desktop/src/app/routes/chats.$chatId.tsx create mode 100644 desktop/src/app/routes/chats.tsx create mode 100644 desktop/src/features/channels/ui/useSideConversation.ts create mode 100644 desktop/src/features/chats/hooks.ts create mode 100644 desktop/src/features/chats/lib/chatActivity.test.mjs create mode 100644 desktop/src/features/chats/lib/chatActivity.ts create mode 100644 desktop/src/features/chats/lib/chatLink.test.mjs create mode 100644 desktop/src/features/chats/lib/chatLink.ts create mode 100644 desktop/src/features/chats/lib/chatProjectStorage.ts create mode 100644 desktop/src/features/chats/lib/chatProjects.ts create mode 100644 desktop/src/features/chats/lib/chatSetup.test.mjs create mode 100644 desktop/src/features/chats/lib/chatSetup.ts create mode 100644 desktop/src/features/chats/lib/remarkChatLinks.ts create mode 100644 desktop/src/features/chats/ui/ChatActivityMarkerRow.tsx create mode 100644 desktop/src/features/chats/ui/ChatActivityTranscript.tsx create mode 100644 desktop/src/features/chats/ui/ChatConversationRows.tsx create mode 100644 desktop/src/features/chats/ui/ChatDetail.tsx create mode 100644 desktop/src/features/chats/ui/ChatHeaderActions.tsx create mode 100644 desktop/src/features/chats/ui/ChatListItem.tsx create mode 100644 desktop/src/features/chats/ui/ChatListSectionHeader.tsx create mode 100644 desktop/src/features/chats/ui/ChatListSkeleton.tsx create mode 100644 desktop/src/features/chats/ui/ChatProjectDialog.tsx create mode 100644 desktop/src/features/chats/ui/ChatsScreen.tsx create mode 100644 desktop/src/features/chats/ui/QuickStartChat.tsx create mode 100644 desktop/src/features/chats/ui/chatActivityIcons.tsx create mode 100644 desktop/src/features/chats/ui/chatActivityText.test.mjs create mode 100644 desktop/src/features/chats/ui/chatActivityText.ts create mode 100644 desktop/src/features/messages/lib/mentionCandidates.ts create mode 100644 desktop/src/shared/api/tauriChats.ts create mode 100644 desktop/src/shared/ui/bubble.tsx create mode 100644 desktop/src/shared/ui/markdown/AppLink.tsx create mode 100644 desktop/src/shared/ui/markdown/ChatLinkCard.tsx create mode 100644 desktop/src/shared/ui/marker.tsx create mode 100644 desktop/src/shared/ui/message-scroller.tsx create mode 100644 desktop/src/shared/ui/message.tsx create mode 100644 migrations/0006_add_chat_channel_type.sql 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..6b1f4568fc 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -81,6 +81,12 @@ 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") +} + /// Resolve the agent's owner pubkey at startup. /// /// Priority: @@ -1373,7 +1379,16 @@ 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"); } @@ -1748,7 +1763,24 @@ 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); + } + 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 +1802,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 +1971,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, diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 6a834f77cd..f8e35af635 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) } } @@ -662,6 +667,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 +678,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 +688,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 +1102,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 +1200,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 +1244,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 +1434,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 +1465,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, @@ -1956,17 +1995,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 +2043,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 +2286,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 +2300,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; } @@ -3129,6 +3403,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 +3862,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 +3905,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 +3950,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 +3977,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 +4006,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..f76fd36708 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] @@ -483,10 +483,7 @@ mod tests { .sql .as_str() .contains("CREATE TABLE scheduled_workflow_fires")); - assert!(migrations[0] - .sql - .as_str() - .contains("CREATE TABLE audit_log")); + assert!(migrations[0].sql.as_str().contains("CREATE TABLE audit_log")); assert!(migrations[0] .sql .as_str() @@ -532,6 +529,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/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/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 68f5e1e786..9b6c8a9de5 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; @@ -52,6 +53,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..2bc5fc6ce7 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -470,6 +470,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/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/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/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 ? ( +
+
+ {items.map((item) => ( + + ))} +
+
+ ) : null} + + + ); +} + +function LiveTurnMarker({ + icon, + startedAt, +}: { + icon: React.ReactNode; + startedAt: string | null; +}) { + const elapsedSeconds = useElapsedSeconds(startedAt); + + return ( + + ); +} + +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 + } + 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..f4870ecd07 --- /dev/null +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -0,0 +1,225 @@ +import * as React from "react"; +import { Bot, FolderGit2, MessageCircle, Power } from "lucide-react"; + +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 { 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, + useMessageScrollerScrollable, +} 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)}` + ); +} + +export function ChatMessageRow({ + event, + isAgent, + isOwn, + profiles, +}: { + event: RelayEvent; + isAgent: boolean; + isOwn: boolean; + profiles?: UserProfileLookup; +}) { + 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; + + return ( + + {!isOwn ? ( + + + + ) : null} + + + + {isOwn ? "You" : displayName} + + + {isAgent ? ( + + ) : ( + + + + )} + + + ); +} + +export function ChatScrollAnchor({ + forceSignature, + signature, +}: { + forceSignature: string | null; + signature: string; +}) { + const { scrollToEnd } = useMessageScroller(); + const scrollable = useMessageScrollerScrollable(); + const lastForcedSignatureRef = React.useRef(null); + + React.useLayoutEffect(() => { + if (signature.length === 0) { + return; + } + + const shouldForce = + forceSignature !== null && + forceSignature !== lastForcedSignatureRef.current; + lastForcedSignatureRef.current = forceSignature; + + if (!shouldForce && scrollable.end) { + return; + } + + scrollToEnd({ behavior: "auto" }); + const frame = window.requestAnimationFrame(() => { + scrollToEnd({ behavior: "auto" }); + }); + return () => window.cancelAnimationFrame(frame); + }, [forceSignature, scrollToEnd, scrollable.end, signature]); + + return null; +} + +export function ChatContextRow({ event }: { event: RelayEvent }) { + const isProjectSetup = event.content.startsWith("Project setup"); + const projectSetupContent = event.content + .replace(/^Project setup\s*\n?/, "") + .trim(); + + if (isProjectSetup) { + return ( + + +
+ + + + + Project setup + +
+ +
+
+
+
+ ); + } + + return ( + + + + + + + Source context + + + + + + + ); +} + +export function AgentActivationCard({ + agentName, + isActivating, + onActivate, +}: { + agentName: string; + isActivating: boolean; + onActivate: () => void; +}) { + return ( + + +
+
+
+
+ +
+
+

+ Activate {agentName} to get a response +

+

+ 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..601abe3583 --- /dev/null +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -0,0 +1,447 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { MessageCircle } from "lucide-react"; +import { toast } from "sonner"; + +import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; +import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; +import { scopeByChannel } from "@/features/agents/ui/agentSessionPanelLayout"; +import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; +import { ChatHeader } from "@/features/chat/ui/ChatHeader"; +import { useUpdateChatMetadataMutation } from "@/features/chats/hooks"; +import { + buildChatActivityPlacement, + shouldHidePersistedAgentMessage, +} from "@/features/chats/lib/chatActivity"; +import { chatProjectForMetadata } from "@/features/chats/lib/chatProjects"; +import { + buildChatCanvasContent, + buildProjectSetupContext, + type ChatProject, + NO_PROJECT_SELECTION_ID, +} from "@/features/chats/lib/chatSetup"; +import { ChatActivityTranscript } from "@/features/chats/ui/ChatActivityTranscript"; +import { isHumanFacingAssistantText } from "@/features/chats/ui/chatActivityText"; +import { + AgentActivationCard, + 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 { KIND_SYSTEM_MESSAGE } 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), + ); +} + +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; + templates: ChannelTemplate[]; +}; + +export function ChatDetail({ + chat, + defaultAgent, + identityPubkey, + isActivatingAgent, + isLoadingMessages, + isSending, + messages, + metadata, + onActivateAgent, + onProjectCreated, + onSend, + profiles, + projects, + shareAction, + templates, +}: ChatDetailProps) { + const queryClient = useQueryClient(); + const updateMetadataMutation = useUpdateChatMetadataMutation(); + const hasObserver = defaultAgent ? isManagedAgentActive(defaultAgent) : false; + const activeAgentTurns = useActiveAgentTurns(defaultAgent?.pubkey); + const isChatTurnActive = React.useMemo( + () => activeAgentTurns.some((turn) => turn.channelId === chat.id), + [activeAgentTurns, chat.id], + ); + const transcript = useAgentTranscript(hasObserver, defaultAgent?.pubkey); + 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], + ); + const selectedProject = React.useMemo( + () => chatProjectForMetadata(metadata), + [metadata], + ); + 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 (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, + templates, + updateMetadataMutation, + ], + ); + const visibleMessages = React.useMemo( + () => + messages.filter((message) => { + if (message.kind === KIND_SYSTEM_MESSAGE) { + return false; + } + const isAgent = + defaultAgent?.pubkey != null && + normalizePubkey(message.pubkey) === + normalizePubkey(defaultAgent.pubkey); + return ( + (eventHasTag(message, "chat_context", "source") || + (isAgent + ? isHumanFacingAssistantText(message.content) + : message.content.trim().length > 0)) && + !shouldHidePersistedAgentMessage({ + event: message, + hiddenAgentMessageIds: chatActivity.hiddenAgentMessageIds, + }) + ); + }), + [chatActivity.hiddenAgentMessageIds, defaultAgent?.pubkey, messages], + ); + const hasTranscriptActivity = chatActivity.totalBlockCount > 0; + 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; + const activationDelayKey = + latestVisibleMessage != null + ? `${latestVisibleMessage.id}:${scopedTranscript.length}` + : ""; + const [showDelayedActivationCard, setShowDelayedActivationCard] = + React.useState(false); + React.useEffect(() => { + if (!activationDelayKey || !latestOwnMessageNeedsAgent || !hasObserver) { + setShowDelayedActivationCard(false); + return; + } + + const timeout = window.setTimeout(() => { + setShowDelayedActivationCard(true); + }, 1_200); + return () => window.clearTimeout(timeout); + }, [activationDelayKey, hasObserver, latestOwnMessageNeedsAgent]); + const shouldShowAgentActivationCard = + latestOwnMessageNeedsAgent && (!hasObserver || showDelayedActivationCard); + const scrollSignature = React.useMemo( + () => + [ + visibleMessages + .map((message) => `${message.id}:${message.content.length}`) + .join(","), + scopedTranscript + .map((item) => { + if (item.type === "message") { + return `${item.id}:message:${item.text.length}`; + } + if (item.type === "tool") { + return `${item.id}:tool:${item.status}:${item.result.length}`; + } + return `${item.id}:${item.type}:${item.timestamp}`; + }) + .join(","), + shouldShowAgentActivationCard ? "activation-card" : "", + ].join("|"), + [scopedTranscript, shouldShowAgentActivationCard, visibleMessages], + ); + 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", + ); + const isAgentMessage = + defaultAgent?.pubkey != null && + normalizePubkey(message.pubkey) === + normalizePubkey(defaultAgent.pubkey); + const isOwnMessage = + identityPubkey != null && + normalizePubkey(message.pubkey) === + normalizePubkey(identityPubkey); + + return ( + + + {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..3692f40483 --- /dev/null +++ b/desktop/src/features/chats/ui/ChatHeaderActions.tsx @@ -0,0 +1,454 @@ +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 { buildChatLink } 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 { 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; +}; + +export function ChatHeaderActions({ + chat, + defaultAgentPubkey, + messages, + metadata, +}: ChatHeaderActionsProps) { + const [isCanvasOpen, setIsCanvasOpen] = React.useState(false); + const [isDefaultAgentOpen, setIsDefaultAgentOpen] = React.useState(false); + + return ( +
+ + + + 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 chatLink = React.useMemo( + () => + buildChatLink({ + chatId: chat.id, + title: metadata?.title?.trim() || chat.name, + }), + [chat.id, chat.name, 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 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/ChatListItem.tsx b/desktop/src/features/chats/ui/ChatListItem.tsx new file mode 100644 index 0000000000..71531b0051 --- /dev/null +++ b/desktop/src/features/chats/ui/ChatListItem.tsx @@ -0,0 +1,111 @@ +import { Archive } from "lucide-react"; + +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Spinner } from "@/shared/ui/spinner"; + +export function ChatListHeader() { + return ( +
+
+
+ ); +} + +export function ChatListItem({ + chat, + getChannelReadAt, + isAgentRunning = false, + isArchiving = false, + onArchiveChat, + onSelectChat, + selectedChatId, + unreadChannelCounts, + unreadChannelIds, +}: { + chat: Channel; + getChannelReadAt: (channelId: string) => number | null; + isAgentRunning?: boolean; + isArchiving?: boolean; + onArchiveChat?: (chatId: string) => void; + onSelectChat: (chatId: string) => void; + selectedChatId: string | null; + unreadChannelCounts: ReadonlyMap; + unreadChannelIds: ReadonlySet; +}) { + 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; + + return ( +
+ + {isAgentRunning || onArchiveChat ? ( +
+ {isAgentRunning ? ( + + ) : null} + {onArchiveChat ? ( + + ) : null} +
+ ) : 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/ChatsScreen.tsx b/desktop/src/features/chats/ui/ChatsScreen.tsx new file mode 100644 index 0000000000..68c3b17e16 --- /dev/null +++ b/desktop/src/features/chats/ui/ChatsScreen.tsx @@ -0,0 +1,790 @@ +// @ts-nocheck +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + ChevronDown, + ChevronRight, + Folder, + MoreVertical, + Plus, +} from "lucide-react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useAppShell } from "@/app/AppShellContext"; +import { useActiveAgentTurnsByChannel } from "@/features/agents/activeAgentTurnsStore"; +import { + useManagedAgentsQuery, + useStartManagedAgentMutation, +} from "@/features/agents/hooks"; +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 { + 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 { 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 { 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 type { + Channel, + ChannelTemplate, + ChatMetadata, +} from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +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); + } +} + +function isSharedChatMetadata( + metadata: ChatMetadata | null | undefined, + identityPubkey: string | null | undefined, +) { + if (!metadata?.authorPubkey || !identityPubkey) { + return false; + } + return ( + normalizePubkey(metadata.authorPubkey) !== normalizePubkey(identityPubkey) + ); +} + +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; + 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), + ] + .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(); + 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 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 ( + defaultAgent.status !== "running" && + defaultAgent.status !== "deployed" + ) { + const updatedAgent = await startManagedAgentMutation.mutateAsync( + defaultAgent.pubkey, + ); + toast.success(`${updatedAgent.name || defaultAgent.name} activated`); + } 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} + shareAction={ + + } + templates={templates} + /> + ) : ( + + upsertStoredChatProject(activeWorkspace?.id, project) + } + onCreated={(chat) => void goChat(chat.id, { replace: true })} + /> + )} +
+
+ ); +} + +function ChatList({ + archivingChatId, + chats, + getChannelReadAt, + identityPubkey, + isLoading, + metadataByChatId, + onArchiveChat, + onCreateChat, + onCreateProjectChat, + onSelectChat, + onUpdateProject, + 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; + onSelectChat: (chatId: string) => void; + 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); + } + } + return { groups, shared, unprojected }; + }, [chats, identityPubkey, metadataByChatId, 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/QuickStartChat.tsx b/desktop/src/features/chats/ui/QuickStartChat.tsx new file mode 100644 index 0000000000..699b4e0dda --- /dev/null +++ b/desktop/src/features/chats/ui/QuickStartChat.tsx @@ -0,0 +1,518 @@ +import * as React from "react"; +import { + Check, + ChevronDown, + Folder, + FolderPlus, + FolderX, + 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 { useManagedAgentsQuery } from "@/features/agents/hooks"; +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 } from "@/features/onboarding/welcomeGuide"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { addChannelMembers, sendChannelMessage } from "@/shared/api/tauri"; +import type { Channel, ChannelTemplate } 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 identityQuery = useIdentityQuery(); + const createChatMutation = useCreateChatMutation(); + const updateMetadataMutation = useUpdateChatMetadataMutation(); + const sendContextMutation = useSendChatContextMessageMutation(); + const templatesQuery = useChannelTemplatesQuery(); + const managedAgentsQuery = useManagedAgentsQuery(); + const { applyAgents, applyCanvas } = useApplyTemplate(); + 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], + ); + + 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); + + const agent = await ensureWelcomeGuideAgentInChannel(chat.id, relayUrl); + 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 = nonAgentMentionPubkeys({ + defaultAgentPubkey: agent.pubkey, + identityPubkey: identityQuery.data?.pubkey, + managedAgentPubkeys: + managedAgentsQuery.data?.map( + (managedAgent) => managedAgent.pubkey, + ) ?? [], + mentionPubkeys, + }); + 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); + } + }, + [ + applyAgents, + applyCanvas, + createChatMutation, + identityQuery.data?.pubkey, + isCreating, + managedAgentsQuery.data, + onCreated, + onProjectCreated, + relayUrl, + selectedProject, + selectedTemplate?.name, + sendContextMutation, + updateMetadataMutation, + ], + ); + + const projectPicker = ( + + ); + + return ( +
+ + +
+
+

Start a chat

+

+ Describe a task or ask a question. +

+
+
+ +
+ +
+
+ ); +} + +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, +}: { + isNoProjectSelected: boolean; + onCreateProject: (project: ChatProject) => void; + onSelectProject: (projectId: string | null) => void; + projects: ChatProject[]; + selectedProject: ChatProject | null; + templates: ChannelTemplate[]; +}) { + 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 ( + <> + + + + + + {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/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/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts new file mode 100644 index 0000000000..a8197cdeab --- /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..c64ac534c1 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -56,6 +56,7 @@ import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; type MessageComposerProps = { + autoInviteNonMemberMentions?: boolean; channelId?: string | null; channelName: string; channelType?: ChannelType | null; @@ -117,12 +118,18 @@ type MessageComposerProps = { id: string; } | null; showTopBorder?: boolean; + toolbarControls?: { + emoji?: boolean; + formatting?: boolean; + spoiler?: boolean; + }; toolbarExtraActions?: React.ReactNode; typingParentEventId?: string | null; typingRootEventId?: string | null; }; function MessageComposerImpl({ + autoInviteNonMemberMentions = false, channelId = null, channelName, channelType = null, @@ -142,6 +149,7 @@ function MessageComposerImpl({ replyTarget = null, mediaController, showTopBorder = false, + toolbarControls, toolbarExtraActions, typingParentEventId = null, typingRootEventId = null, @@ -161,11 +169,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 +213,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, @@ -246,7 +282,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 +335,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(); @@ -328,6 +368,7 @@ function MessageComposerImpl({ mentions, onSendRef, richText, + autoInviteNonMemberMentions, setContent: setComposerContent, setIsEmojiPickerOpen, setPendingImeta: media.setPendingImeta, @@ -642,20 +683,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 +731,7 @@ function MessageComposerImpl({ [ emojiAutocomplete.handleEmojiKeyDown, applyEmojiInsert, + showEmojiControls, channelLinks.handleChannelKeyDown, applyChannelInsert, mentions.handleMentionKeyDown, @@ -873,15 +913,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..f9f3d1e4c3 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -1,12 +1,24 @@ 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, + 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. */ @@ -34,6 +46,10 @@ export const MessageComposerToolbar = React.memo( onOpenMentionPicker, onPaperclip, sendDisabled, + showEmojiPicker, + showFormatting, + showSpoiler, + spoilerActive, }: { composerDisabled: boolean; editor: Editor | null; @@ -51,14 +67,51 @@ export const MessageComposerToolbar = React.memo( onOpenMentionPicker: () => void; onPaperclip: () => void; 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 +123,7 @@ export const MessageComposerToolbar = React.memo( * no order hacks, no overflow clipping needed. */} - {isFormattingOpen ? ( + {isFormattingOpen && shouldShowFormatting ? ( /* * ── Expanded: [Aa] [✕] | [formatting buttons] ── */ @@ -192,38 +245,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} )} 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/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/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/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 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/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/styles/globals/animations.css b/desktop/src/shared/styles/globals/animations.css index 3cc935ec40..11e6c0e1a6 100644 --- a/desktop/src/shared/styles/globals/animations.css +++ b/desktop/src/shared/styles/globals/animations.css @@ -123,7 +123,23 @@ transform-origin: center; } -.buzz-shimmer { +.scroll-fade-b { + --scroll-fade-size: 2rem; + + mask-image: linear-gradient( + to bottom, + black calc(100% - var(--scroll-fade-size)), + transparent + ); + -webkit-mask-image: linear-gradient( + to bottom, + black calc(100% - var(--scroll-fade-size)), + transparent + ); +} + +.buzz-shimmer, +.shimmer { --buzz-shimmer-duration: 2000ms; --buzz-shimmer-band: 400%; --buzz-shimmer-highlight: color-mix( @@ -137,7 +153,8 @@ position: relative; } -.buzz-shimmer::before { +.buzz-shimmer::before, +.shimmer::before { animation: buzz-shimmer var(--buzz-shimmer-duration) linear infinite; background-clip: text; background-image: linear-gradient( @@ -170,7 +187,8 @@ } @media (prefers-reduced-motion: reduce) { - .buzz-shimmer::before { + .buzz-shimmer::before, + .shimmer::before { animation: none; } } diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index fdc25d2873..cd406c6e93 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; } 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/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 8275c1b3c1..48fdb0314e 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -14,11 +14,9 @@ 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 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"; @@ -73,7 +71,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 +1558,7 @@ function createMarkdownComponents( ), a: ({ children, href, ...props }) => { - const { imetaByUrl, onOpenMessageLink } = runtimeRef.current; + const { imetaByUrl } = runtimeRef.current; if (!interactive) { return {children}; } @@ -1592,37 +1594,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 +1864,7 @@ function createMarkdownComponents( const channel = channels.find( (c) => c.channelType !== "dm" && + c.channelType !== "chat" && c.name.toLowerCase() === channelName.toLowerCase(), ); @@ -1914,23 +1895,20 @@ 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; @@ -1953,7 +1931,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); @@ -1976,6 +1954,12 @@ function MarkdownInner({ }, [goChannel], ); + const onOpenChatLink = React.useCallback( + (link: ParsedChatLink) => { + void goChat(link.chatId); + }, + [goChat], + ); const linkPreviews = React.useMemo( () => (interactive ? extractSupportedLinkPreviews(content) : []), [content, interactive], @@ -1989,6 +1973,7 @@ function MarkdownInner({ channels, imetaByUrl, mentionPubkeysByName, + onOpenChatLink, onOpenChannel, onOpenMessageLink, }); @@ -2004,6 +1989,7 @@ function MarkdownInner({ remarkGfm, remarkBreaks, remarkSpoilers, + remarkChatLinks, remarkMessageLinks, [remarkMentions, { mentionNames }], [remarkChannelLinks, { channelNames }], 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..beea8852f6 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,11 +24,20 @@ 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; }; 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 ( +
); } @@ -594,8 +665,11 @@ function ChatList({ onArchiveChat, onCreateChat, onCreateProjectChat, + onRenameChat, onSelectChat, + onTogglePin, onUpdateProject, + pinnedChatIds, projects, readStateVersion: _readStateVersion, selectedChatId, @@ -612,7 +686,10 @@ function ChatList({ 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; @@ -655,8 +732,23 @@ function ChatList({ unprojected.push(chat); } } - return { groups, shared, unprojected }; - }, [chats, identityPubkey, metadataByChatId, projects]); + 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) => { @@ -695,7 +787,7 @@ function ChatList({ return (
- +
} + icon={} label="New project" onSelect={() => { setIsCreateOpen(true); @@ -442,7 +442,7 @@ export function ProjectPicker({ /> } + icon={} label="No project" onSelect={() => { onSelectProject(NO_PROJECT_SELECTION_ID); @@ -479,7 +479,7 @@ function ProjectPickerRow({ onClick={onSelect} type="button" > - + {project.name} {project.path ? ( diff --git a/desktop/tests/e2e/chats-switch-repro.spec.ts b/desktop/tests/e2e/chats-switch-repro.spec.ts index dd285efbce..2ee8ba9575 100644 --- a/desktop/tests/e2e/chats-switch-repro.spec.ts +++ b/desktop/tests/e2e/chats-switch-repro.spec.ts @@ -58,4 +58,29 @@ test("switching chats does not stack headers", async ({ page }) => { await expect(page.getByTestId("chat-title")).toContainText( "Second chat about bananas", ); + + // Right-click offers rename/pin/archive; pinning moves the chat to the top + // of its section. + await first.click({ button: "right" }); + await expect( + page.getByRole("menuitem", { name: "Rename chat" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Archive chat" }), + ).toBeVisible(); + await page.getByRole("menuitem", { name: "Pin chat" }).click(); + const chatNames = page + .getByRole("button", { name: /chat about/ }) + .filter({ hasNotText: "Archive" }); + await expect(chatNames.first()).toHaveText(/First chat about apples/); + + // Rename through the context menu updates the sidebar and header. + await first.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Rename chat" }).click(); + const renameInput = page.getByLabel("Chat name"); + await renameInput.fill("Apple planning"); + await page.getByRole("button", { name: "Rename", exact: true }).click(); + await expect( + page.getByRole("button", { exact: true, name: "Apple planning" }), + ).toBeVisible(); }); From 60a67a9f3b3bf33a42ae0d2071b2979afa9d9144 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 10:17:47 +0100 Subject: [PATCH 11/68] Keep sidebar shimmer titles readable and truncating Two fixes to the working-chat shimmer in the sidebar: - New buzz-shimmer-accent variant: the title keeps its normal text color and a primary-colored band sweeps across it, instead of the default treatment that dims the base text to make the highlight read. - The shimmer class now lives on the truncating span itself (the MarkerLabel pattern), so long names ellipsize normally. The archive slot is zero-width until hover/focus, giving the title the full row and only shrinking to make room for the button on demand. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/ui/ChatListItem.tsx | 18 ++++++++++++++---- .../src/shared/styles/globals/animations.css | 12 ++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatListItem.tsx b/desktop/src/features/chats/ui/ChatListItem.tsx index 9e4c962510..a89cbd1b3a 100644 --- a/desktop/src/features/chats/ui/ChatListItem.tsx +++ b/desktop/src/features/chats/ui/ChatListItem.tsx @@ -10,7 +10,6 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/shared/ui/context-menu"; -import { Shimmer } from "@/shared/ui/Shimmer"; export function ChatListHeader() { return ( @@ -83,8 +82,17 @@ export function ChatListItem({ onClick={() => onSelectChat(chat.id)} type="button" > - - {isAgentRunning ? {name} : name} + + {name} {isPinned ? ( {onArchiveChat ? ( -
+ // Zero-width until hover/focus so the title gets the full row; the + // slot expands to make room for the archive button on demand. +
{onArchiveChat ? ( // Zero-width until hover/focus so the title gets the full row; the - // slot expands to make room for the archive button on demand. -
+ // slot appears instantly to make room for the archive button. +
- {agentPullRequestHref ? ( - + {workPanelHref && showWorkPanel ? ( + ) : null}
diff --git a/desktop/src/features/chats/ui/ChatHeaderActions.tsx b/desktop/src/features/chats/ui/ChatHeaderActions.tsx index 3692f40483..4f8a924b8e 100644 --- a/desktop/src/features/chats/ui/ChatHeaderActions.tsx +++ b/desktop/src/features/chats/ui/ChatHeaderActions.tsx @@ -50,6 +50,8 @@ type ChatHeaderActionsProps = { defaultAgentPubkey?: string | null; messages: RelayEvent[]; metadata: ChatMetadata | null; + /** Rendered between Share and the settings menu (work panel toggle). */ + workPanelToggle?: React.ReactNode; }; export function ChatHeaderActions({ @@ -57,6 +59,7 @@ export function ChatHeaderActions({ defaultAgentPubkey, messages, metadata, + workPanelToggle, }: ChatHeaderActionsProps) { const [isCanvasOpen, setIsCanvasOpen] = React.useState(false); const [isDefaultAgentOpen, setIsDefaultAgentOpen] = React.useState(false); @@ -81,6 +84,7 @@ export function ChatHeaderActions({ messages={messages} metadata={metadata} /> + {workPanelToggle} setIsCanvasOpen(true)} onOpenDefaultAgent={() => setIsDefaultAgentOpen(true)} 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/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index bc3337a7f9..079fdeaa84 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -5,7 +5,7 @@ import { useGithubPullRequestQuery, } from "@/shared/lib/githubPullRequest"; import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview"; -import { AgentPullRequestCard } from "@/shared/ui/link-preview-attachment"; +import { GithubPullRequestCard } from "@/shared/ui/link-preview-attachment"; /** * Right-hand work module for a chat whose agent produced a pull request: @@ -36,7 +36,7 @@ export function ChatWorkPanel({ prHref }: { prHref: string }) { {branch}
) : null} - + ); } diff --git a/desktop/src/features/chats/ui/ChatsScreen.tsx b/desktop/src/features/chats/ui/ChatsScreen.tsx index 250f6b65af..6446523dfa 100644 --- a/desktop/src/features/chats/ui/ChatsScreen.tsx +++ b/desktop/src/features/chats/ui/ChatsScreen.tsx @@ -2,17 +2,10 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { - ChevronDown, - ChevronRight, - MoreVertical, - Notebook, - Plus, -} from "lucide-react"; +import { GitPullRequest } from "lucide-react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useAppShell } from "@/app/AppShellContext"; -import { useActiveAgentTurnsByChannel } from "@/features/agents/activeAgentTurnsStore"; import { useManagedAgentsQuery, useStartManagedAgentMutation, @@ -26,6 +19,8 @@ import { 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, @@ -42,10 +37,6 @@ import { } from "@/features/chats/lib/chatSetup"; import { ChatDetail } from "@/features/chats/ui/ChatDetail"; import { ChatHeaderActions } from "@/features/chats/ui/ChatHeaderActions"; -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 { ChatRenameDialog } from "@/features/chats/ui/ChatRenameDialog"; import { QuickStartChat } from "@/features/chats/ui/QuickStartChat"; import { @@ -58,19 +49,10 @@ 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 type { - Channel, - ChannelTemplate, - ChatMetadata, -} from "@/shared/api/types"; +import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview"; +import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/shared/ui/dropdown-menu"; type ChatsScreenProps = { initialProjectId?: string | null; @@ -100,18 +82,6 @@ async function addBotToChat(channelId: string, pubkey: string) { } } -function isSharedChatMetadata( - metadata: ChatMetadata | null | undefined, - identityPubkey: string | null | undefined, -) { - if (!metadata?.authorPubkey || !identityPubkey) { - return false; - } - return ( - normalizePubkey(metadata.authorPubkey) !== normalizePubkey(identityPubkey) - ); -} - export function ChatsScreen({ initialProjectId, selectedChatId = null, @@ -339,6 +309,36 @@ export function ChatsScreen({ identityQuery.data, ); const archiveChatMutation = useArchiveChatMutation(); + + // Latest PR link the chat's agent posted — drives the header toggle and + // the top-right work module in the conversation. + const agentPullRequestHref = React.useMemo(() => { + const agentPubkey = metadata?.defaultAgentPubkey ?? defaultAgent?.pubkey; + if (!agentPubkey) { + return null; + } + const agentKey = normalizePubkey(agentPubkey); + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (normalizePubkey(message.pubkey) !== agentKey) { + continue; + } + const preview = extractSupportedLinkPreviews(message.content).find( + (candidate) => candidate.kind === "github-pull-request", + ); + if (preview) { + return preview.href; + } + } + return null; + }, [defaultAgent?.pubkey, messages, metadata?.defaultAgentPubkey]); + const [isWorkPanelOpen, setIsWorkPanelOpen] = React.useState(true); + const workPanelChatRef = React.useRef(selectedChatId); + if (workPanelChatRef.current !== selectedChatId) { + // Each chat starts with its work module visible. + workPanelChatRef.current = selectedChatId; + setIsWorkPanelOpen(true); + } const startManagedAgentMutation = useStartManagedAgentMutation(); const [isEnsuringDefaultAgent, setIsEnsuringDefaultAgent] = React.useState(false); @@ -616,12 +616,36 @@ export function ChatsScreen({ onSend={handleSend} profiles={profiles} projects={chatProjects} + showWorkPanel={isWorkPanelOpen} + workPanelHref={agentPullRequestHref} shareAction={ setIsWorkPanelOpen((open) => !open)} + size="icon" + type="button" + variant="ghost" + > + + + ) : null + } /> } templates={templates} @@ -654,295 +678,3 @@ export function ChatsScreen({
); } - -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/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index a0889e3246..8bba0895cf 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -141,15 +141,23 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { ) .toBeGreaterThanOrEqual(-1); - // Agent-authored PR links render the prominent agent-work card variant - // twice: inline in the message and in the top-right work panel (which - // also shows the PR's source branch). + // Agent-authored PR links render the prominent agent-work card inline in + // the message; the top-right work panel shows the standard rich card plus + // the PR's source branch. await expect( page.locator("[data-link-preview='github-pull-request-agent']"), - ).toHaveCount(2, { timeout: 10_000 }); - await expect(page.getByTestId("chat-work-panel")).toBeVisible(); - await expect(page.getByTestId("chat-work-panel")).toContainText( - "kennylopez-chatmode", - ); + ).toHaveCount(1, { timeout: 10_000 }); + const workPanel = page.getByTestId("chat-work-panel"); + await expect(workPanel).toBeVisible(); + await expect(workPanel).toContainText("kennylopez-chatmode"); + await expect( + workPanel.locator("[data-link-preview='github-pull-request']"), + ).toBeVisible(); + + // The header's PR button toggles the panel. + await page.getByTestId("toggle-work-panel").click(); + await expect(workPanel).not.toBeVisible(); + await page.getByTestId("toggle-work-panel").click(); + await expect(workPanel).toBeVisible(); await page.screenshot({ path: "test-results/agent-pr-card.png" }); }); From 4224f9d511f5dfe15766a648a93bed69ebc8d4a2 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 11:37:23 +0100 Subject: [PATCH 18/68] Drop the leading icon from the chat header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat title stands alone in the header — the MessageCircle glyph added no information. Other modes keep their icons. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chat/ui/ChatHeader.tsx | 26 ++++++++++----------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 92477d0a23..1421b5cfeb 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -8,7 +8,6 @@ import { Hash, House, Lock, - MessageCircle, Zap, } from "lucide-react"; import type * as React from "react"; @@ -78,10 +77,6 @@ function ChannelIcon({ return ; } - if (mode === "chats") { - return ; - } - if (mode === "workflows") { return ; } @@ -153,15 +148,18 @@ export function ChatHeader({
-
- {leadingContent ?? ( - - )} -
+ {/* Chats render no leading icon — the title stands alone. */} + {leadingContent || mode !== "chats" ? ( +
+ {leadingContent ?? ( + + )} +
+ ) : null}

Date: Sat, 4 Jul 2026 11:46:52 +0100 Subject: [PATCH 19/68] Style the work drawer as a secondary-surface container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The work module's contents (label, branch chip, PR card) sit in a rounded secondary-background container, the divider against the conversation is gone, and the drawer eases open/closed on its width (300ms ease-out) instead of popping — the panel stays mounted so the close animates too. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/ui/ChatDetail.tsx | 4 +- .../src/features/chats/ui/ChatWorkPanel.tsx | 43 +++++++++++++------ desktop/tests/e2e/chats-first-message.spec.ts | 2 + 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index 67e06cbe71..1f3300987d 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -624,8 +624,8 @@ export function ChatDetail({ />

- {workPanelHref && showWorkPanel ? ( - + {workPanelHref ? ( + ) : null}
diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 079fdeaa84..f811eee265 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -5,14 +5,22 @@ import { useGithubPullRequestQuery, } from "@/shared/lib/githubPullRequest"; import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview"; +import { cn } from "@/shared/lib/cn"; import { GithubPullRequestCard } from "@/shared/ui/link-preview-attachment"; /** * Right-hand work module for a chat whose agent produced a pull request: - * the PR's source branch and the live PR card (status, diff stats, link). - * The conversation column and composer shrink to make room. + * the PR's source branch and the live PR card (status, diff stats, link), + * grouped in a secondary-surface container. The drawer eases open/closed on + * its width so the conversation column and composer slide to make room. */ -export function ChatWorkPanel({ prHref }: { prHref: string }) { +export function ChatWorkPanel({ + open = true, + prHref, +}: { + open?: boolean; + prHref: string; +}) { const preview = parseSupportedLinkPreview(prHref); const ref = parseGithubPullRequestRef(prHref); const query = useGithubPullRequestQuery(ref); @@ -24,19 +32,28 @@ export function ChatWorkPanel({ prHref }: { prHref: string }) { return ( ); } diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 8bba0895cf..00bb848cc4 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from "@playwright/test"; +import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; test("first message in a new chat is sent and rendered", async ({ page }) => { @@ -159,5 +160,6 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { await expect(workPanel).not.toBeVisible(); await page.getByTestId("toggle-work-panel").click(); await expect(workPanel).toBeVisible(); + await waitForAnimations(page); await page.screenshot({ path: "test-results/agent-pr-card.png" }); }); From c3d49f111cee4b0387db955274324d0c8df97cea Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 11:49:41 +0100 Subject: [PATCH 20/68] Give the work panel's PR card its own surface The card's default muted background matched the panel's secondary container exactly; it now uses the same background inset as the branch chip so both read as distinct items on the surface. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/ui/ChatWorkPanel.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index f811eee265..19fa2cec1a 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -51,7 +51,12 @@ export function ChatWorkPanel({ {branch}
) : null} - +
From 8f8c4a70e6f86fde927b49ff763edbaf6e833ade Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 11:51:28 +0100 Subject: [PATCH 21/68] Drop the Work label from the chat work panel The branch chip and PR card speak for themselves. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/ui/ChatWorkPanel.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 19fa2cec1a..13ec17b124 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -42,9 +42,6 @@ export function ChatWorkPanel({ {/* Fixed-width inner wrapper so content never reflows mid-slide. */}
-
- Work -
{branch ? (
From 0640bd31b2b2c9864b5c5d0ae3e37cf12a9f816e Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 11:53:13 +0100 Subject: [PATCH 22/68] Soften the work panel container to a muted wash bg-secondary read too heavy next to the conversation; bg-muted/40 keeps the grouping while blending with the chat background. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/ui/ChatWorkPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 13ec17b124..5cd5daa147 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -41,7 +41,7 @@ export function ChatWorkPanel({ > {/* Fixed-width inner wrapper so content never reflows mid-slide. */}
-
+
{branch ? (
From 090d5f6d9c07b7d86c5acf8dde8a3efaa85828b4 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 11:57:34 +0100 Subject: [PATCH 23/68] Reserve the rich PR card for the chat work drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain dropped GitHub PR links go back to the compact static chip they had before; the live rich card (status, diff stats) now appears only in the chat work drawer — at the same compact-attachment scale as the generic chips — and agent-authored messages keep their banner variant. Co-Authored-By: Claude Fable 5 --- desktop/src/shared/ui/markdown.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 93a4092cd5..0dba1fed9a 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -38,7 +38,6 @@ import { AttachmentGroup } from "@/shared/ui/attachment"; import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment"; import { AgentPullRequestCard, - GithubPullRequestCard, LinkPreviewAttachment, } from "@/shared/ui/link-preview-attachment"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; @@ -2078,13 +2077,11 @@ function MarkdownInner({ data-link-preview-list="" > {resolvedLinkPreviews.map((preview) => - preview.kind === "github-pull-request" ? ( - agentAuthored ? ( - - ) : ( - - ) + 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. ), )} From f8a84deb634d7cbc6928f4624521a330c63b426f Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 12:05:13 +0100 Subject: [PATCH 24/68] Widen the work drawer and match generic card styling The drawer grows to w-96 so the PR card's stats fit, and the container wash is gone: the branch chip and card use the same attachment styling as the generic link chips, directly on the chat background. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/ui/ChatWorkPanel.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 5cd5daa147..4941db6e9b 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -35,25 +35,21 @@ export function ChatWorkPanel({ aria-hidden={!open} className={cn( "flex shrink-0 flex-col overflow-hidden transition-[width,opacity] duration-300 ease-out", - open ? "w-80 opacity-100" : "pointer-events-none w-0 opacity-0", + open ? "w-96 opacity-100" : "pointer-events-none w-0 opacity-0", )} data-testid="chat-work-panel" > {/* Fixed-width inner wrapper so content never reflows mid-slide. */} -
-
+
+
{branch ? ( -
+ // Same attachment styling as the generic link chips. +
{branch}
) : null} - +
From 8aa9839884dac81cf32794445101882821688114 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 12:17:18 +0100 Subject: [PATCH 25/68] Always offer the work drawer; lighten the Share button The header's PR toggle now shows for every chat: the drawer auto-opens once the agent produces a pull request and shows a "No current branch" empty state before that (explicit toggles override per chat). The Share button trades its filled pill for a light border stroke with a hover fill. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/ui/ChatDetail.tsx | 4 +- .../features/chats/ui/ChatHeaderActions.tsx | 4 +- .../src/features/chats/ui/ChatWorkPanel.tsx | 34 ++++++------- desktop/src/features/chats/ui/ChatsScreen.tsx | 48 ++++++++++--------- 4 files changed, 45 insertions(+), 45 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index 1f3300987d..ae668f034a 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -624,9 +624,7 @@ export function ChatDetail({ />
- {workPanelHref ? ( - - ) : null} +
); diff --git a/desktop/src/features/chats/ui/ChatHeaderActions.tsx b/desktop/src/features/chats/ui/ChatHeaderActions.tsx index 4f8a924b8e..9a36dedb7f 100644 --- a/desktop/src/features/chats/ui/ChatHeaderActions.tsx +++ b/desktop/src/features/chats/ui/ChatHeaderActions.tsx @@ -344,10 +344,10 @@ function ChatShareMenu({
diff --git a/desktop/src/features/chats/ui/ChatsScreen.tsx b/desktop/src/features/chats/ui/ChatsScreen.tsx index 6446523dfa..4f8eb68678 100644 --- a/desktop/src/features/chats/ui/ChatsScreen.tsx +++ b/desktop/src/features/chats/ui/ChatsScreen.tsx @@ -332,13 +332,17 @@ export function ChatsScreen({ } return null; }, [defaultAgent?.pubkey, messages, metadata?.defaultAgentPubkey]); - const [isWorkPanelOpen, setIsWorkPanelOpen] = React.useState(true); + // null = auto: open once the agent has produced a PR, closed otherwise. + // A click stores an explicit preference for the current chat. + const [workPanelPreference, setWorkPanelPreference] = React.useState< + boolean | null + >(null); const workPanelChatRef = React.useRef(selectedChatId); if (workPanelChatRef.current !== selectedChatId) { - // Each chat starts with its work module visible. workPanelChatRef.current = selectedChatId; - setIsWorkPanelOpen(true); + setWorkPanelPreference(null); } + const isWorkPanelOpen = workPanelPreference ?? agentPullRequestHref !== null; const startManagedAgentMutation = useStartManagedAgentMutation(); const [isEnsuringDefaultAgent, setIsEnsuringDefaultAgent] = React.useState(false); @@ -625,26 +629,24 @@ export function ChatsScreen({ messages={messages} metadata={metadata} workPanelToggle={ - agentPullRequestHref ? ( - - ) : null + } /> } From e24f4a8c8abc8616959bc4d3a5ba8dd781fe9ada Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 12:26:03 +0100 Subject: [PATCH 26/68] Defer opening the rename dialog past the context menu's close Opening the dialog synchronously from the menu item's onSelect races Radix's menu close/focus-restore against the dialog's focus lock and can freeze the webview (observed as an app crash on rename). Deferring one tick lets the menu fully close first. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/ui/ChatListItem.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/chats/ui/ChatListItem.tsx b/desktop/src/features/chats/ui/ChatListItem.tsx index ac400a6135..7b0bcd3ba8 100644 --- a/desktop/src/features/chats/ui/ChatListItem.tsx +++ b/desktop/src/features/chats/ui/ChatListItem.tsx @@ -141,7 +141,14 @@ export function ChatListItem({ {row} {onRenameChat && canRename ? ( - onRenameChat(chat.id)}> + { + // 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 From 1ee49bce2f5d72ead161a292190765dabb121f75 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 12:40:59 +0100 Subject: [PATCH 27/68] CI monitor and automation toggles in the chat work panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The work drawer becomes a PR monitor: a CI chip shows running (with progress), failing (red, count), or passing (green) from the head commit's check runs, alongside the PR's comment count — both polled while the panel is mounted. Two persisted per-chat checkboxes arm automation: "Auto-fix CI failures" prompts the chat's agent to investigate and fix once a head sha's checks settle red (one nudge per sha), and "Address comments & resolve" prompts it to work through comments and replies, reply, and resolve addressed threads whenever the comment count rises (watermarked so it never repeats). Adds fetch_github_check_summary and head sha/comment counts to the PR fetch. Co-Authored-By: Claude Fable 5 --- .../src-tauri/src/commands/link_preview.rs | 91 +++++++++ desktop/src-tauri/src/lib.rs | 1 + .../features/chats/lib/chatWorkAutomation.ts | 90 +++++++++ desktop/src/features/chats/ui/ChatDetail.tsx | 7 +- .../src/features/chats/ui/ChatWorkPanel.tsx | 186 ++++++++++++++++-- desktop/src/shared/lib/githubPullRequest.ts | 44 +++++ desktop/src/testing/e2eBridge.ts | 5 + desktop/tests/e2e/chats-first-message.spec.ts | 6 + 8 files changed, 418 insertions(+), 12 deletions(-) create mode 100644 desktop/src/features/chats/lib/chatWorkAutomation.ts diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 086c7016bd..ea015d877a 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -25,6 +25,23 @@ pub struct GithubPullRequestInfo { 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, } /// Fetch live PR details from the GitHub REST API. @@ -83,6 +100,80 @@ pub async fn fetch_github_pull_request( 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 = reqwest::Client::builder() + .pool_idle_timeout(Duration::from_secs(10)) + .pool_max_idle_per_host(1) + .build() + .map_err(|error| format!("github client failed: {error}"))?; + + 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().is_success() { + return Ok(None); + } + + let body: serde_json::Value = response + .json() + .await + .map_err(|error| format!("github response parse failed: {error}"))?; + + let runs = body["check_runs"].as_array().cloned().unwrap_or_default(); + let mut pending = 0; + let mut failed = 0; + let mut succeeded = 0; + for run in &runs { + match run["status"].as_str().unwrap_or_default() { + "completed" => match run["conclusion"].as_str().unwrap_or_default() { + "success" | "neutral" | "skipped" => succeeded += 1, + _ => failed += 1, + }, + _ => pending += 1, + } + } + + Ok(Some(GithubCheckSummary { + total: runs.len() as i64, + pending, + failed, + succeeded, })) } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 93c5fb3fd8..579acfadfd 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -460,6 +460,7 @@ pub fn run() { get_media_proxy_port, fetch_link_preview_title, fetch_github_pull_request, + fetch_github_check_summary, discover_acp_providers, install_acp_runtime, discover_managed_agent_prereqs, diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts new file mode 100644 index 0000000000..a8e76af32d --- /dev/null +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -0,0 +1,90 @@ +import * as React from "react"; + +// Per-chat automation preferences for the work panel, plus watermarks that +// keep the auto-prompts from repeating (one CI nudge per failing head sha, +// one comment nudge per count increase). Local state: the prompts are sent +// from this client into the chat, so they never need to sync. +const STORAGE_PREFIX = "buzz:chat-work-automation:v1"; +const STORAGE_EVENT = "buzz:chat-work-automation-changed"; + +export type ChatWorkAutomation = { + autoFixCi: boolean; + addressComments: boolean; + /** Head sha of the last CI failure the agent was asked to fix. */ + lastCiNudgeSha: string | null; + /** Comment total at the last address-comments nudge. */ + lastCommentNudgeCount: number | null; +}; + +const DEFAULTS: ChatWorkAutomation = { + autoFixCi: false, + addressComments: false, + lastCiNudgeSha: null, + lastCommentNudgeCount: null, +}; + +function storageKey(chatId: string) { + return `${STORAGE_PREFIX}:${chatId}`; +} + +export function readChatWorkAutomation(chatId: string): ChatWorkAutomation { + if (typeof window === "undefined") { + return DEFAULTS; + } + try { + const raw = window.localStorage.getItem(storageKey(chatId)); + if (!raw) { + return DEFAULTS; + } + const parsed = JSON.parse(raw) as Partial; + return { + autoFixCi: Boolean(parsed.autoFixCi), + addressComments: Boolean(parsed.addressComments), + lastCiNudgeSha: + typeof parsed.lastCiNudgeSha === "string" + ? parsed.lastCiNudgeSha + : null, + lastCommentNudgeCount: + typeof parsed.lastCommentNudgeCount === "number" + ? parsed.lastCommentNudgeCount + : null, + }; + } catch { + return DEFAULTS; + } +} + +export function updateChatWorkAutomation( + chatId: string, + patch: Partial, +) { + if (typeof window === "undefined") { + return; + } + try { + const next = { ...readChatWorkAutomation(chatId), ...patch }; + window.localStorage.setItem(storageKey(chatId), JSON.stringify(next)); + window.dispatchEvent(new CustomEvent(STORAGE_EVENT)); + } catch { + // Preferences are a convenience layer; ignore unavailable storage. + } +} + +export function useChatWorkAutomation(chatId: string): ChatWorkAutomation { + const [state, setState] = React.useState(() => + readChatWorkAutomation(chatId), + ); + + React.useEffect(() => { + const refresh = () => setState(readChatWorkAutomation(chatId)); + refresh(); + window.addEventListener(STORAGE_EVENT, refresh); + window.addEventListener("storage", refresh); + return () => { + window.removeEventListener(STORAGE_EVENT, refresh); + window.removeEventListener("storage", refresh); + }; + }, [chatId]); + + return state; +} diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index ae668f034a..00ce76f7c9 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -624,7 +624,12 @@ export function ChatDetail({ />
- + void onSend(content, [])} + open={showWorkPanel} + prHref={workPanelHref} + />
); diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 924292ac34..d854da93ac 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -1,30 +1,94 @@ -import { GitBranch } from "lucide-react"; +import * as React from "react"; +import { + CircleCheck, + CircleDashed, + CircleX, + GitBranch, + LoaderCircle, + MessageSquareText, +} from "lucide-react"; +import { + updateChatWorkAutomation, + useChatWorkAutomation, +} from "@/features/chats/lib/chatWorkAutomation"; import { parseGithubPullRequestRef, + useGithubCheckSummaryQuery, useGithubPullRequestQuery, } from "@/shared/lib/githubPullRequest"; import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview"; import { cn } from "@/shared/lib/cn"; +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"; + /** - * Right-hand work drawer for a chat: the PR's source branch and the live PR - * card once the agent has produced a pull request, or an empty state until - * then. The drawer eases open/closed on its width so the conversation column - * and composer slide to make room. + * Right-hand work drawer for a chat: branch, live PR card, CI monitor, and + * automation toggles once the agent has produced a pull request; an empty + * state before that. When automation is armed, CI failures and new comments + * prompt the chat's agent automatically (deduped per head sha / comment + * count). */ export function ChatWorkPanel({ + chatId, + onAutomationPrompt, open = true, prHref, }: { + chatId: string; + onAutomationPrompt?: (content: string) => void; open?: boolean; prHref?: string | null; }) { const preview = prHref ? parseSupportedLinkPreview(prHref) : null; const ref = prHref ? parseGithubPullRequestRef(prHref) : null; - const query = useGithubPullRequestQuery(ref); - const branch = query.data?.headRef?.trim(); + const prQuery = useGithubPullRequestQuery(ref); + const pr = prQuery.data ?? null; + const checksQuery = useGithubCheckSummaryQuery(ref, pr?.headSha); + const checks = checksQuery.data ?? null; + const automation = useChatWorkAutomation(chatId); + const commentTotal = pr ? pr.comments + pr.reviewComments : 0; + + // Automation: prompt the agent on CI failure / new comments. Watermarks in + // storage keep this to one nudge per failing sha and per comment increase. + React.useEffect(() => { + if (!onAutomationPrompt || !prHref || !pr) { + return; + } + if ( + automation.autoFixCi && + checks && + checks.failed > 0 && + checks.pending === 0 && + automation.lastCiNudgeSha !== pr.headSha + ) { + updateChatWorkAutomation(chatId, { lastCiNudgeSha: pr.headSha }); + onAutomationPrompt( + `CI is failing on ${prHref} (${checks.failed} of ${checks.total} checks). Investigate the failures and push fixes until the checks pass.`, + ); + } + if ( + automation.addressComments && + commentTotal > 0 && + (automation.lastCommentNudgeCount ?? 0) < commentTotal + ) { + updateChatWorkAutomation(chatId, { lastCommentNudgeCount: commentTotal }); + onAutomationPrompt( + `There are review comments on ${prHref}. Address each comment and its replies, push any needed changes, reply to the threads, and resolve every conversation that has been addressed.`, + ); + } + }, [ + automation, + chatId, + checks, + commentTotal, + onAutomationPrompt, + pr, + prHref, + ]); return ( ); } + +function CiStatus({ + checks, +}: { + checks: { + total: number; + pending: number; + failed: number; + succeeded: number; + } | 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/shared/lib/githubPullRequest.ts b/desktop/src/shared/lib/githubPullRequest.ts index 0ff7e47e65..f0a92ed8c3 100644 --- a/desktop/src/shared/lib/githubPullRequest.ts +++ b/desktop/src/shared/lib/githubPullRequest.ts @@ -13,6 +13,19 @@ export type GithubPullRequestInfo = { 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; }; export type GithubPullRequestRef = { @@ -69,6 +82,37 @@ export function useGithubPullRequestQuery(ref: GithubPullRequestRef | null) { }, )) ?? 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, }); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index fbba4c282e..724adc6564 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8888,6 +8888,8 @@ export function maybeInstallE2eTauriMocks() { ); case "get_media_proxy_port": return MOCK_MEDIA_PROXY_PORT; + case "fetch_github_check_summary": + return { total: 4, pending: 0, failed: 0, succeeded: 4 }; case "fetch_github_pull_request": { // Deterministic PR details so the rich GitHub card renders in mocks. const prPayload = payload as { number?: number }; @@ -8900,6 +8902,9 @@ export function maybeInstallE2eTauriMocks() { deletions: 96, changedFiles: 24, headRef: "kennylopez-chatmode", + headSha: "deadbeefcafe0000000000000000000000000000", + comments: 2, + reviewComments: 1, number: prPayload.number ?? 0, }; } diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 00bb848cc4..746e9ebd44 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -155,6 +155,12 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { workPanel.locator("[data-link-preview='github-pull-request']"), ).toBeVisible(); + // CI monitor and automation toggles render alongside the card. + await expect(page.getByTestId("chat-ci-monitor")).toContainText("CI passing"); + await expect(page.getByTestId("chat-ci-monitor")).toContainText("3 comments"); + await expect(page.getByTestId("automation-auto-fix-ci")).toBeVisible(); + await expect(page.getByTestId("automation-address-comments")).toBeVisible(); + // The header's PR button toggles the panel. await page.getByTestId("toggle-work-panel").click(); await expect(workPanel).not.toBeVisible(); From e787698a767a6c12592971ea1e4ca07caf5e061d Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 13:06:55 +0100 Subject: [PATCH 28/68] Expandable CI monitor with per-check runs and open-comment count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI chip in the chat work panel is now a dropdown: the summary row keeps the aggregate state plus a chevron, and expanding it lists each check run with its own pass/fail/pending icon, followed by the two automation checkboxes (moved out of their standalone chip). The comment count now shows review threads still awaiting the PR author's reply instead of the raw total — REST can't see GitHub's resolved bit, so replying is what clears a thread. New fetch_github_pr_comment_state command groups review comments into threads and counts the ones whose last word isn't the author's; the address-comments automation nudges on rises in that count and re-arms once it returns to zero. Co-Authored-By: Claude Fable 5 --- .../src-tauri/src/commands/link_preview.rs | 132 +++++++++++- desktop/src-tauri/src/lib.rs | 1 + .../src/features/chats/ui/ChatWorkPanel.tsx | 201 ++++++++++-------- desktop/src/shared/lib/githubPullRequest.ts | 34 +++ desktop/src/testing/e2eBridge.ts | 15 +- desktop/tests/e2e/chats-first-message.spec.ts | 11 +- 6 files changed, 299 insertions(+), 95 deletions(-) diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index ea015d877a..1da7734e95 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -42,6 +42,27 @@ pub struct GithubCheckSummary { 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. @@ -155,28 +176,123 @@ pub async fn fetch_github_check_summary( .await .map_err(|error| format!("github response parse failed: {error}"))?; - let runs = body["check_runs"].as_array().cloned().unwrap_or_default(); + let raw_runs = body["check_runs"].as_array().cloned().unwrap_or_default(); let mut pending = 0; let mut failed = 0; let mut succeeded = 0; - for run in &runs { - match run["status"].as_str().unwrap_or_default() { + 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, - _ => failed += 1, + "success" | "neutral" | "skipped" => { + succeeded += 1; + "success" + } + _ => { + failed += 1; + "failure" + } }, - _ => pending += 1, - } + _ => { + pending += 1; + "pending" + } + }; + runs.push(GithubCheckRun { + name: run["name"].as_str().unwrap_or("check").to_string(), + state: state.to_string(), + }); } Ok(Some(GithubCheckSummary { - total: runs.len() as i64, + 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 = reqwest::Client::builder() + .pool_idle_timeout(Duration::from_secs(10)) + .pool_max_idle_per_host(1) + .build() + .map_err(|error| format!("github client failed: {error}"))?; + + 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().is_success() { + return Ok(None); + } + 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().is_success() { + return Ok(None); + } + 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 })) +} + fn ambient_github_token() -> Option { ["GITHUB_TOKEN", "GH_TOKEN"].iter().find_map(|name| { std::env::var(name) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 579acfadfd..fa42960c7e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -461,6 +461,7 @@ pub fn run() { fetch_link_preview_title, fetch_github_pull_request, fetch_github_check_summary, + fetch_github_pr_comment_state, discover_acp_providers, install_acp_runtime, discover_managed_agent_prereqs, diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index d854da93ac..1ac9f3a566 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { + ChevronDown, CircleCheck, CircleDashed, CircleX, @@ -13,8 +14,10 @@ import { useChatWorkAutomation, } from "@/features/chats/lib/chatWorkAutomation"; import { + type GithubCheckSummary, parseGithubPullRequestRef, useGithubCheckSummaryQuery, + useGithubCommentStateQuery, useGithubPullRequestQuery, } from "@/shared/lib/githubPullRequest"; import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview"; @@ -26,11 +29,11 @@ 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"; /** - * Right-hand work drawer for a chat: branch, live PR card, CI monitor, and - * automation toggles once the agent has produced a pull request; an empty - * state before that. When automation is armed, CI failures and new comments - * prompt the chat's agent automatically (deduped per head sha / comment - * count). + * 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({ chatId, @@ -49,11 +52,14 @@ export function ChatWorkPanel({ const pr = prQuery.data ?? null; const checksQuery = useGithubCheckSummaryQuery(ref, pr?.headSha); const checks = checksQuery.data ?? null; + const commentStateQuery = useGithubCommentStateQuery(ref); + const openThreads = commentStateQuery.data?.openThreads ?? 0; const automation = useChatWorkAutomation(chatId); - const commentTotal = pr ? pr.comments + pr.reviewComments : 0; - // Automation: prompt the agent on CI failure / new comments. Watermarks in - // storage keep this to one nudge per failing sha and per comment increase. + // Automation: prompt the agent on CI failure / newly-open review threads. + // Watermarks in storage keep this to one nudge per failing sha and per + // rise in open threads; the thread watermark re-arms once everything has + // been replied to (count back at zero). React.useEffect(() => { if (!onAutomationPrompt || !prHref || !pr) { return; @@ -70,25 +76,16 @@ export function ChatWorkPanel({ `CI is failing on ${prHref} (${checks.failed} of ${checks.total} checks). Investigate the failures and push fixes until the checks pass.`, ); } - if ( - automation.addressComments && - commentTotal > 0 && - (automation.lastCommentNudgeCount ?? 0) < commentTotal - ) { - updateChatWorkAutomation(chatId, { lastCommentNudgeCount: commentTotal }); + const threadWatermark = automation.lastCommentNudgeCount ?? 0; + if (openThreads === 0 && threadWatermark !== 0) { + updateChatWorkAutomation(chatId, { lastCommentNudgeCount: 0 }); + } else if (automation.addressComments && openThreads > threadWatermark) { + updateChatWorkAutomation(chatId, { lastCommentNudgeCount: openThreads }); onAutomationPrompt( - `There are review comments on ${prHref}. Address each comment and its replies, push any needed changes, reply to the threads, and resolve every conversation that has been addressed.`, + `There are unanswered review comments on ${prHref}. Address each comment and its replies, push any needed changes, reply to the threads, and resolve every conversation that has been addressed.`, ); } - }, [ - automation, - chatId, - checks, - commentTotal, - onAutomationPrompt, - pr, - prHref, - ]); + }, [automation, chatId, checks, onAutomationPrompt, openThreads, pr, prHref]); return (
@@ -172,16 +190,33 @@ export function ChatWorkPanel({ ); } -function CiStatus({ - checks, -}: { - checks: { - total: number; - pending: number; - failed: number; - succeeded: number; - } | null; -}) { +/** 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 ( <> diff --git a/desktop/src/shared/lib/githubPullRequest.ts b/desktop/src/shared/lib/githubPullRequest.ts index f0a92ed8c3..2df0b38682 100644 --- a/desktop/src/shared/lib/githubPullRequest.ts +++ b/desktop/src/shared/lib/githubPullRequest.ts @@ -26,6 +26,16 @@ export type GithubCheckSummary = { 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 = { @@ -116,3 +126,27 @@ export function useGithubCheckSummaryQuery( 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/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 724adc6564..d4fd120205 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8889,7 +8889,20 @@ export function maybeInstallE2eTauriMocks() { case "get_media_proxy_port": return MOCK_MEDIA_PROXY_PORT; case "fetch_github_check_summary": - return { total: 4, pending: 0, failed: 0, succeeded: 4 }; + return { + total: 4, + pending: 0, + failed: 0, + succeeded: 4, + runs: [ + { name: "ci / lint", state: "success" }, + { name: "ci / unit-tests", state: "success" }, + { name: "ci / desktop-build", state: "success" }, + { name: "ci / integration", state: "success" }, + ], + }; + case "fetch_github_pr_comment_state": + return { openThreads: 2 }; case "fetch_github_pull_request": { // Deterministic PR details so the rich GitHub card renders in mocks. const prPayload = payload as { number?: number }; diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 746e9ebd44..6a0543e51c 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -155,9 +155,14 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { workPanel.locator("[data-link-preview='github-pull-request']"), ).toBeVisible(); - // CI monitor and automation toggles render alongside the card. - await expect(page.getByTestId("chat-ci-monitor")).toContainText("CI passing"); - await expect(page.getByTestId("chat-ci-monitor")).toContainText("3 comments"); + // CI monitor summary shows check state plus unreplied review threads. + const ciMonitor = page.getByTestId("chat-ci-monitor"); + await expect(ciMonitor).toContainText("CI passing"); + await expect(ciMonitor).toContainText("2 open comments"); + // Collapsed by default; expanding reveals the runs + automation toggles. + await expect(page.getByTestId("automation-auto-fix-ci")).not.toBeVisible(); + await ciMonitor.locator("summary").click(); + await expect(ciMonitor).toContainText("ci / unit-tests"); await expect(page.getByTestId("automation-auto-fix-ci")).toBeVisible(); await expect(page.getByTestId("automation-address-comments")).toBeVisible(); From 463c74d5a20c1a2030f2cad142fb2a08cabf5a4d Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 13:15:16 +0100 Subject: [PATCH 29/68] Show the agent's live branch in the work panel, animated on change The branch chip previously only knew head.ref from the GitHub PR, so it sat on "No current branch" until a PR existed and snapped when it did. The chat's channel-scoped transcript already carries the agent's shell commands, so the panel now derives the current branch from the latest `git worktree add` / `checkout -b` / `switch` the agent ran (live activity wins over the PR's head ref) and swaps the chip text with the same character animation the chat title uses. File checkouts, `--` restores, and bare shas are ignored so only real branch moves update the chip. Co-Authored-By: Claude Fable 5 --- .../chats/lib/chatWorkBranch.test.mjs | 93 +++++++++++++ .../src/features/chats/lib/chatWorkBranch.ts | 124 ++++++++++++++++++ desktop/src/features/chats/ui/ChatDetail.tsx | 8 ++ .../src/features/chats/ui/ChatWorkPanel.tsx | 23 +++- 4 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 desktop/src/features/chats/lib/chatWorkBranch.test.mjs create mode 100644 desktop/src/features/chats/lib/chatWorkBranch.ts diff --git a/desktop/src/features/chats/lib/chatWorkBranch.test.mjs b/desktop/src/features/chats/lib/chatWorkBranch.test.mjs new file mode 100644 index 0000000000..cdf9339f10 --- /dev/null +++ b/desktop/src/features/chats/lib/chatWorkBranch.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deriveChatWorkBranch, + parseBranchFromCommand, +} from "./chatWorkBranch.ts"; + +function toolItem(command, overrides = {}) { + return { + id: overrides.id ?? command, + type: "tool", + renderClass: "generic", + descriptor: { category: "shell" }, + title: "Ran command", + toolName: "shell", + buzzToolName: null, + status: "completed", + args: { command }, + result: "", + isError: false, + timestamp: "2026-07-04T00:00:00Z", + startedAt: "2026-07-04T00:00:00Z", + completedAt: null, + channelId: overrides.channelId ?? "chat-1", + turnId: overrides.turnId ?? "turn-1", + }; +} + +test("worktree add with -b names the new branch", () => { + assert.equal( + parseBranchFromCommand("git worktree add ../wt/feature -b feature-x"), + "feature-x", + ); +}); + +test("worktree add with existing branch uses the second positional", () => { + assert.equal( + parseBranchFromCommand("git worktree add ../wt/fix fix-panel"), + "fix-panel", + ); +}); + +test("worktree add with only a path uses the basename", () => { + assert.equal( + parseBranchFromCommand("git worktree add /tmp/worktrees/chat-panel"), + "chat-panel", + ); +}); + +test("checkout -b and switch -c name the branch", () => { + assert.equal( + parseBranchFromCommand("git checkout -b kenny/new-panel"), + "kenny/new-panel", + ); + assert.equal(parseBranchFromCommand("git switch -c wip"), "wip"); +}); + +test("plain switch to an existing branch counts", () => { + assert.equal(parseBranchFromCommand("git switch main"), "main"); +}); + +test("compound commands take the last branch operation", () => { + assert.equal( + parseBranchFromCommand( + "cd /repo && git fetch origin && git worktree add ../wt -b first && git -C ../wt checkout -b second", + ), + "second", + ); +}); + +test("file checkouts, detached heads, and non-git commands are ignored", () => { + assert.equal(parseBranchFromCommand("git checkout -- src/app.ts"), null); + assert.equal(parseBranchFromCommand("git checkout src/a.ts src/b.ts"), null); + assert.equal(parseBranchFromCommand("git checkout deadbeefcafe"), null); + assert.equal(parseBranchFromCommand("cargo build --release"), null); + assert.equal(parseBranchFromCommand("echo git checkout"), null); +}); + +test("deriveChatWorkBranch returns the latest branch across the transcript", () => { + const transcript = [ + toolItem("ls -la", { id: "1" }), + toolItem("git worktree add ../wt -b first-branch", { id: "2" }), + { id: "3", type: "lifecycle", renderClass: "status" }, + toolItem("git checkout -b second-branch", { id: "4" }), + toolItem("cargo test", { id: "5" }), + ]; + assert.equal(deriveChatWorkBranch(transcript), "second-branch"); +}); + +test("deriveChatWorkBranch is null without branch activity", () => { + assert.equal(deriveChatWorkBranch([toolItem("pnpm test")]), null); +}); diff --git a/desktop/src/features/chats/lib/chatWorkBranch.ts b/desktop/src/features/chats/lib/chatWorkBranch.ts new file mode 100644 index 0000000000..8e74a9a55f --- /dev/null +++ b/desktop/src/features/chats/lib/chatWorkBranch.ts @@ -0,0 +1,124 @@ +import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; +import { getToolString } from "@/features/agents/ui/agentSessionUtils"; + +/** + * Derive the branch the chat's agent is currently working on from its shell + * activity: the latest `git worktree add` / `git checkout` / `git switch` + * that names a branch wins. This is what lets the work panel show a branch + * as soon as the agent sets up a worktree — before any PR exists to report + * `head.ref`. + */ +export function deriveChatWorkBranch( + transcript: readonly TranscriptItem[], +): string | null { + let branch: string | null = null; + for (const item of transcript) { + if (item.type !== "tool") { + continue; + } + const command = getToolString(item.args, ["command"]); + if (!command) { + continue; + } + const parsed = parseBranchFromCommand(command); + if (parsed) { + branch = parsed; + } + } + return branch; +} + +/** Latest branch named by any segment of a (possibly compound) command. */ +export function parseBranchFromCommand(command: string): string | null { + let branch: string | null = null; + for (const segment of command.split(/&&|\|\||;|\n/)) { + const parsed = parseBranchFromSegment(segment.trim()); + if (parsed) { + branch = parsed; + } + } + return branch; +} + +function parseBranchFromSegment(segment: string): string | null { + const tokens = tokenize(segment); + const gitIndex = tokens.indexOf("git"); + if (gitIndex === -1) { + return null; + } + // Skip `git -C …` style global flags between `git` and the verb. + let verbIndex = gitIndex + 1; + while (verbIndex < tokens.length && tokens[verbIndex].startsWith("-")) { + verbIndex += tokens[verbIndex] === "-C" ? 2 : 1; + } + const verb = tokens[verbIndex]; + const rest = tokens.slice(verbIndex + 1); + + if (verb === "worktree" && rest[0] === "add") { + return parseWorktreeAdd(rest.slice(1)); + } + if (verb === "checkout" || verb === "switch") { + return parseCheckoutOrSwitch(rest); + } + return null; +} + +function parseWorktreeAdd(args: string[]): string | null { + const flagged = valueOfFlag(args, ["-b", "-B"]); + if (flagged) { + return flagged; + } + const positional = args.filter((token) => !token.startsWith("-")); + if (positional.length >= 2) { + // `git worktree add ` — a commit-ish second arg (sha) + // isn't a branch name worth showing. + return looksLikeSha(positional[1]) ? null : positional[1]; + } + if (positional.length === 1) { + // `git worktree add ` creates a branch named after the basename. + const parts = positional[0].split("/").filter(Boolean); + return parts[parts.length - 1] || null; + } + return null; +} + +function parseCheckoutOrSwitch(args: string[]): string | null { + const flagged = valueOfFlag(args, ["-b", "-B", "-c", "-C"]); + if (flagged) { + return flagged; + } + // Plain `git checkout ` / `git switch `: only a single + // non-flag argument reads as a branch move — more args means file paths, + // and `--` restores files, and a bare sha is a detached head. + if (args.includes("--")) { + return null; + } + const positional = args.filter((token) => !token.startsWith("-")); + if (positional.length !== 1 || looksLikeSha(positional[0])) { + return null; + } + return positional[0]; +} + +function valueOfFlag(args: string[], flags: string[]): string | null { + for (let index = 0; index < args.length; index += 1) { + if (flags.includes(args[index])) { + const value = args[index + 1]; + if (value && !value.startsWith("-")) { + return value; + } + } + } + return null; +} + +function looksLikeSha(token: string): boolean { + return /^[0-9a-f]{7,40}$/i.test(token); +} + +function tokenize(segment: string): string[] { + return segment + .split(/\s+/) + .map((token) => token.replace(/^['"]|['"]$/g, "")) + .filter((token) => token.length > 0); +} diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index 00ce76f7c9..5a1edd530e 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -26,6 +26,7 @@ import { NO_PROJECT_SELECTION_ID, } from "@/features/chats/lib/chatSetup"; import { ChatActivityTranscript } from "@/features/chats/ui/ChatActivityTranscript"; +import { deriveChatWorkBranch } from "@/features/chats/lib/chatWorkBranch"; import { ChatWorkPanel } from "@/features/chats/ui/ChatWorkPanel"; import { isHumanFacingAssistantText } from "@/features/chats/ui/chatActivityText"; import { entranceClassForCreatedAt } from "@/features/chats/ui/messageEntrance"; @@ -144,6 +145,12 @@ export function ChatDetail({ }), [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. + const workBranch = React.useMemo( + () => deriveChatWorkBranch(scopedTranscript), + [scopedTranscript], + ); const selectedProject = React.useMemo( () => chatProjectForMetadata(metadata), [metadata], @@ -625,6 +632,7 @@ export function ChatDetail({
void onSend(content, [])} open={showWorkPanel} diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 1ac9f3a566..5d7a7fe373 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -22,6 +22,7 @@ import { } 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 { Checkbox } from "@/shared/ui/checkbox"; import { GithubPullRequestCard } from "@/shared/ui/link-preview-attachment"; @@ -36,11 +37,14 @@ const CHIP_CLASS = * chat's agent automatically (deduped per head sha / open-thread watermark). */ export function ChatWorkPanel({ + branch = null, chatId, onAutomationPrompt, open = true, prHref, }: { + /** Live branch from the agent's worktree/checkout activity, if any. */ + branch?: string | null; chatId: string; onAutomationPrompt?: (content: string) => void; open?: boolean; @@ -55,6 +59,9 @@ export function ChatWorkPanel({ const commentStateQuery = useGithubCommentStateQuery(ref); const openThreads = commentStateQuery.data?.openThreads ?? 0; const automation = useChatWorkAutomation(chatId); + // 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. + const currentBranch = branch?.trim() || pr?.headRef?.trim() || null; // Automation: prompt the agent on CI failure / newly-open review threads. // Watermarks in storage keep this to one nudge per failing sha and per @@ -101,13 +108,15 @@ export function ChatWorkPanel({
- {pr?.headRef?.trim() ? ( - - {pr.headRef.trim()} - - ) : ( - No current branch - )} + {/* Swaps with the title animation when the agent moves to a new + worktree/branch (or the placeholder resolves to a branch). */} +
{preview ? ( From ba8ac20fecfcba9fc9f85b8b8947d23d4e73f83e Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 14:56:37 +0100 Subject: [PATCH 30/68] Make the accent shimmer contrasty and robust to mid-turn attach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contrast: the accent shimmer band swept raw primary, which sits too close to the text's lightness in both themes (58%-L purple vs near-black text in light; 80%-L purple vs near-white text in dark) and barely read. The band now mixes primary toward the background — lighter in light mode, darker in dark mode, both directions pulling away from the text — and narrows so the highlight covers more visible text per pass. Robustness: observer frames are ephemeral, so an app subscribing mid-turn missed turn_started and the turns store stayed empty (dark sidebar shimmer, no working badges) until a liveness ping happened to land. Any frame carrying a turn context — tool calls included, which stream constantly during work — now establishes the turn, still gated by the terminal tombstone so completed turns stay dead. Adds an e2e spec covering the sidebar title shimmer end to end via seeded active turns. Co-Authored-By: Claude Fable 5 --- .../agents/activeAgentTurnsStore.test.mjs | 46 ++++++++++++++++ .../features/agents/activeAgentTurnsStore.ts | 21 +++++-- .../src/shared/styles/globals/animations.css | 17 +++++- desktop/tests/e2e/chats-first-message.spec.ts | 55 +++++++++++++++++++ 4 files changed, 132 insertions(+), 7 deletions(-) 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 08a58170a0..020109a011 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -354,12 +354,23 @@ function processEvent(agentPubkey: string, event: ObserverEvent) { return; case "acp_read": case "acp_write": + case "turn_liveness": + // 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(); diff --git a/desktop/src/shared/styles/globals/animations.css b/desktop/src/shared/styles/globals/animations.css index cca99cf1b0..028c47697d 100644 --- a/desktop/src/shared/styles/globals/animations.css +++ b/desktop/src/shared/styles/globals/animations.css @@ -211,9 +211,22 @@ /* 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. */ + 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. */ .buzz-shimmer-accent { - --buzz-shimmer-highlight: hsl(var(--primary)); + --buzz-shimmer-highlight: color-mix( + in oklab, + hsl(var(--primary)) 55%, + hsl(var(--background)) + ); + --buzz-shimmer-band: 300%; color: inherit; } diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 6a0543e51c..3c8f833897 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -174,3 +174,58 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { await waitForAnimations(page); await page.screenshot({ path: "test-results/agent-pr-card.png" }); }); + +test("sidebar chat title shimmers while the agent has an active turn", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/#/chats"); + + const composer = page.locator("[contenteditable='true'], textarea").first(); + await expect(composer).toBeVisible(); + await composer.click(); + await composer.fill("Spin up a new worktree to look at the panel"); + await composer.press("Enter"); + await expect(page).toHaveURL(/\/chats\/.+/); + const chatId = decodeURIComponent( + new URL(page.url().replace("/#/", "/")).pathname.split("/").at(-1) ?? "", + ); + expect(chatId.length).toBeGreaterThan(0); + + // Quiet agent: no shimmer on the sidebar title. + const shimmerTitle = page.locator( + "[data-shimmer-text*='Spin up a new worktree']", + ); + await expect(shimmerTitle).toHaveCount(0); + + // Seed an active turn for this chat — the row title must pick up the + // accent shimmer (class + animated overlay), same store the working row + // reads. + await page.evaluate( + ({ channelId }) => { + ( + window as Window & { + __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: { + agentPubkey: string; + channelId: string; + turnId: string; + }) => void; + } + ).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ + agentPubkey: "ab".repeat(32), + channelId, + turnId: "turn-shimmer-1", + }); + }, + { channelId: chatId }, + ); + + await expect(shimmerTitle).toHaveCount(1); + await expect(shimmerTitle).toHaveClass(/buzz-shimmer/); + await expect(shimmerTitle).toHaveClass(/buzz-shimmer-accent/); + expect( + await shimmerTitle.evaluate( + (element) => window.getComputedStyle(element, "::before").animationName, + ), + ).toBe("buzz-shimmer"); +}); From e369ad801e38811ee63b18a22a3b1885de847a5c Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 15:11:26 +0100 Subject: [PATCH 31/68] Composer stop button, message-derived branch, muted markers, shimmer punch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop button: while the chat's agent has an active turn, the composer's send button becomes a stop button that sends the cancel-turn control frame to the agent; a user message in flight keeps the sending spinner. (MessageComposerProps moves to a sibling file to stay under the 1000-line ceiling.) Branch chip: tool activity only exists from observer-subscription time, so a worktree created before the app attached left "No current branch" even though the agent had announced it. The agent's persisted messages are now a fallback source — "…on branch kennylopez-dictation", backticked branch names, and quoted git commands all parse. Markers: the completed-work expander label and tool preview text drop to muted-foreground so every marker row reads as secondary to the conversation. Shimmer: per-theme band colors — light keeps primary washed toward white; dark mixes primary toward black in oklch, which keeps the hue saturated instead of graying into the background, for a clearly stronger sweep against near-white text. Co-Authored-By: Claude Fable 5 --- .../chats/lib/chatWorkBranch.test.mjs | 50 +++++++ .../src/features/chats/lib/chatWorkBranch.ts | 53 ++++++- .../chats/ui/ChatActivityTranscript.tsx | 4 +- desktop/src/features/chats/ui/ChatDetail.tsx | 27 +++- .../features/messages/ui/MessageComposer.tsx | 129 ++++-------------- .../messages/ui/MessageComposerToolbar.tsx | 55 +++++--- .../messages/ui/messageComposerProps.ts | 84 ++++++++++++ .../src/shared/styles/globals/animations.css | 13 +- desktop/src/testing/e2eBridge.ts | 17 +++ desktop/tests/e2e/chats-first-message.spec.ts | 54 ++++++-- 10 files changed, 345 insertions(+), 141 deletions(-) create mode 100644 desktop/src/features/messages/ui/messageComposerProps.ts diff --git a/desktop/src/features/chats/lib/chatWorkBranch.test.mjs b/desktop/src/features/chats/lib/chatWorkBranch.test.mjs index cdf9339f10..cecd8b5457 100644 --- a/desktop/src/features/chats/lib/chatWorkBranch.test.mjs +++ b/desktop/src/features/chats/lib/chatWorkBranch.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + deriveBranchFromAgentMessages, deriveChatWorkBranch, parseBranchFromCommand, } from "./chatWorkBranch.ts"; @@ -91,3 +92,52 @@ test("deriveChatWorkBranch returns the latest branch across the transcript", () test("deriveChatWorkBranch is null without branch activity", () => { assert.equal(deriveChatWorkBranch([toolItem("pnpm test")]), null); }); + +const AGENT_PK = "cd".repeat(32); + +test("agent messages announcing a worktree branch are parsed", () => { + const messages = [ + { pubkey: "ff".repeat(32), content: "please make a worktree" }, + { + pubkey: AGENT_PK, + content: + "Done! Created a new worktree at /Users/k/Development/sprout-dictation " + + "on branch kennylopez-dictation, based off latest main.", + }, + ]; + assert.equal( + deriveBranchFromAgentMessages(messages, AGENT_PK), + "kennylopez-dictation", + ); +}); + +test("backticked branch names and quoted commands in messages parse too", () => { + assert.equal( + deriveBranchFromAgentMessages( + [{ pubkey: AGENT_PK, content: "I pushed the branch `fix/panel-width`." }], + AGENT_PK, + ), + "fix/panel-width", + ); + assert.equal( + deriveBranchFromAgentMessages( + [{ pubkey: AGENT_PK, content: "Ran `git checkout -b quick-fix` first." }], + AGENT_PK, + ), + "quick-fix", + ); +}); + +test("non-agent messages and branchless text derive nothing", () => { + assert.equal( + deriveBranchFromAgentMessages( + [ + { pubkey: "ff".repeat(32), content: "on branch user-branch" }, + { pubkey: AGENT_PK, content: "All tests pass now." }, + ], + AGENT_PK, + ), + null, + ); + assert.equal(deriveBranchFromAgentMessages([], null), null); +}); diff --git a/desktop/src/features/chats/lib/chatWorkBranch.ts b/desktop/src/features/chats/lib/chatWorkBranch.ts index 8e74a9a55f..1226b7d278 100644 --- a/desktop/src/features/chats/lib/chatWorkBranch.ts +++ b/desktop/src/features/chats/lib/chatWorkBranch.ts @@ -28,6 +28,57 @@ export function deriveChatWorkBranch( return branch; } +/** + * Fallback when tool activity predates the observer subscription (frames are + * ephemeral): the agent's persisted chat messages usually announce the + * branch — "Created a new worktree at … on branch kennylopez-dictation". + * Parses "on branch " / "branch ``" phrasing plus any quoted git + * commands in the text; the last mention across the messages wins. + */ +export function deriveBranchFromAgentMessages( + messages: readonly { pubkey: string; content: string }[], + agentPubkey: string | null | undefined, +): string | null { + if (!agentPubkey) { + return null; + } + let branch: string | null = null; + for (const message of messages) { + if (message.pubkey !== agentPubkey) { + continue; + } + const parsed = + parseBranchFromCommand(message.content) ?? + parseBranchFromProse(message.content); + if (parsed) { + branch = parsed; + } + } + return branch; +} + +const PROSE_BRANCH_PATTERNS = [ + /\bon (?:the )?branch\s+[`'"]?([A-Za-z0-9._/-]+?)[`'".,)]*(?:\s|$)/i, + /\bbranch\s+[`'"]([A-Za-z0-9._/-]+)[`'"]/i, +]; + +function parseBranchFromProse(text: string): string | null { + let branch: string | null = null; + for (const pattern of PROSE_BRANCH_PATTERNS) { + const flags = pattern.flags.includes("g") + ? pattern.flags + : `${pattern.flags}g`; + const global = new RegExp(pattern.source, flags); + for (const match of text.matchAll(global)) { + const candidate = match[1]; + if (candidate && !looksLikeSha(candidate)) { + branch = candidate; + } + } + } + return branch; +} + /** Latest branch named by any segment of a (possibly compound) command. */ export function parseBranchFromCommand(command: string): string | null { let branch: string | null = null; @@ -119,6 +170,6 @@ function looksLikeSha(token: string): boolean { function tokenize(segment: string): string[] { return segment .split(/\s+/) - .map((token) => token.replace(/^['"]|['"]$/g, "")) + .map((token) => token.replace(/^[`'"]+|[`'".,]+$/g, "")) .filter((token) => token.length > 0); } diff --git a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx index b0d42d130a..028cb2b08c 100644 --- a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx +++ b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx @@ -544,7 +544,7 @@ function CompletedWorkMarker({ items }: { items: TranscriptItem[] }) { onClick={() => setIsOpen((current) => !current)} type="button" > - + {label} {summary.preview && summary.kind !== "shell" ? ( - + {summary.preview} ) : null} diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index 5a1edd530e..a6a39f1fff 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -26,7 +26,11 @@ import { NO_PROJECT_SELECTION_ID, } from "@/features/chats/lib/chatSetup"; import { ChatActivityTranscript } from "@/features/chats/ui/ChatActivityTranscript"; -import { deriveChatWorkBranch } from "@/features/chats/lib/chatWorkBranch"; +import { + deriveBranchFromAgentMessages, + deriveChatWorkBranch, +} from "@/features/chats/lib/chatWorkBranch"; +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"; @@ -147,10 +151,26 @@ export function ChatDetail({ ); // 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 workBranch = React.useMemo( - () => deriveChatWorkBranch(scopedTranscript), - [scopedTranscript], + () => + deriveChatWorkBranch(scopedTranscript) ?? + deriveBranchFromAgentMessages(messages, defaultAgent?.pubkey), + [defaultAgent?.pubkey, messages, scopedTranscript], ); + const handleStopAgent = React.useCallback(() => { + if (!defaultAgent?.pubkey) { + return; + } + cancelManagedAgentTurn(defaultAgent.pubkey, chat.id).catch( + (error: unknown) => { + console.error("Failed to stop agent turn", error); + toast.error("Could not stop the agent"); + }, + ); + }, [chat.id, defaultAgent?.pubkey]); const selectedProject = React.useMemo( () => chatProjectForMetadata(metadata), [metadata], @@ -611,6 +631,7 @@ export function ChatDetail({ draftKey={`chat:${chat.id}`} isSending={isSending} onSend={onSend} + onStopAgent={isChatTurnActive ? handleStopAgent : null} placeholder="Message Fizz..." profiles={profiles} toolbarControls={{ diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index c64ac534c1..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,79 +49,7 @@ import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useMentionSendFlow } from "./useMentionSendFlow"; import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; - -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; - 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; -}; +import type { MessageComposerProps } from "./messageComposerProps"; function MessageComposerImpl({ autoInviteNonMemberMentions = false, @@ -144,6 +67,7 @@ function MessageComposerImpl({ onEditLastOwnMessage, onEditSave, onSend, + onStopAgent = null, placeholder, profiles, replyTarget = null, @@ -231,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" }); @@ -357,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, @@ -996,6 +920,7 @@ function MessageComposerImpl({ onLinkButton={linkEditor.openFromToolbar} onOpenMentionPicker={openMentionPicker} onPaperclip={handlePaperclipClick} + onStopAgent={onStopAgent} sendDisabled={sendDisabled} showEmojiPicker={showEmojiControls} showFormatting={showFormattingControls} diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index f9f3d1e4c3..6d03805c64 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -7,6 +7,7 @@ import { AtSign, HatGlasses, Paperclip, + Square, X, } from "lucide-react"; @@ -45,6 +46,7 @@ export const MessageComposerToolbar = React.memo( onLinkButton, onOpenMentionPicker, onPaperclip, + onStopAgent, sendDisabled, showEmojiPicker, showFormatting, @@ -66,6 +68,12 @@ 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; @@ -311,23 +319,36 @@ export const MessageComposerToolbar = React.memo(
{extraActions} - + {onStopAgent && !isSending ? ( + + ) : ( + + )}
); 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/shared/styles/globals/animations.css b/desktop/src/shared/styles/globals/animations.css index 028c47697d..db61cf3569 100644 --- a/desktop/src/shared/styles/globals/animations.css +++ b/desktop/src/shared/styles/globals/animations.css @@ -219,17 +219,26 @@ (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. */ + 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 { --buzz-shimmer-highlight: color-mix( in oklab, - hsl(var(--primary)) 55%, + hsl(var(--primary)) 40%, hsl(var(--background)) ); --buzz-shimmer-band: 300%; color: inherit; } +.dark .buzz-shimmer-accent { + --buzz-shimmer-highlight: color-mix(in oklch, hsl(var(--primary)) 60%, black); +} + @media (prefers-reduced-motion: reduce) { .buzz-shimmer, .shimmer { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index d4fd120205..cd22a7f317 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8903,6 +8903,23 @@ export function maybeInstallE2eTauriMocks() { }; case "fetch_github_pr_comment_state": return { openThreads: 2 }; + case "build_observer_control_event": { + // Specs assert the invocation (via __BUZZ_E2E_COMMANDS__); the event + // itself only needs to be publishable over the mock websocket. + const controlPayload = payload as { agentPubkey?: string }; + return JSON.stringify({ + id: "c0".repeat(32), + pubkey: "ab".repeat(32), + created_at: Math.floor(Date.now() / 1_000), + kind: 24200, + tags: [ + ["frame", "control"], + ["agent", controlPayload.agentPubkey ?? ""], + ], + content: "", + sig: "00".repeat(64), + }); + } case "fetch_github_pull_request": { // Deterministic PR details so the rich GitHub card renders in mocks. const prPayload = payload as { number?: number }; diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 3c8f833897..1e9c6d2878 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -198,21 +198,31 @@ test("sidebar chat title shimmers while the agent has an active turn", async ({ ); await expect(shimmerTitle).toHaveCount(0); - // Seed an active turn for this chat — the row title must pick up the - // accent shimmer (class + animated overlay), same store the working row - // reads. + // While no turn is active the composer offers plain send. + await expect(page.getByTestId("send-message")).toBeVisible(); + await expect(page.getByTestId("stop-agent")).toHaveCount(0); + + // Seed an active turn for this chat's agent — the row title must pick up + // the accent shimmer (class + animated overlay), same store the working + // row reads, and the composer's send button must become a stop button. await page.evaluate( - ({ channelId }) => { - ( - window as Window & { - __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: { - agentPubkey: string; - channelId: string; - turnId: string; - }) => void; - } - ).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ - agentPubkey: "ab".repeat(32), + async ({ channelId }) => { + const win = window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: unknown, + ) => Promise; + __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: { + agentPubkey: string; + channelId: string; + turnId: string; + }) => void; + }; + const agents = (await win.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.( + "list_managed_agents", + )) as Array<{ pubkey: string }>; + win.__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ + agentPubkey: agents[0]?.pubkey ?? "ab".repeat(32), channelId, turnId: "turn-shimmer-1", }); @@ -228,4 +238,20 @@ test("sidebar chat title shimmers while the agent has an active turn", async ({ (element) => window.getComputedStyle(element, "::before").animationName, ), ).toBe("buzz-shimmer"); + + // Send became stop; clicking it sends the cancel-turn control frame. + const stopButton = page.getByTestId("stop-agent"); + await expect(stopButton).toBeVisible(); + await expect(page.getByTestId("send-message")).toHaveCount(0); + await stopButton.click(); + await expect + .poll(() => + page.evaluate(() => + ( + (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }) + .__BUZZ_E2E_COMMANDS__ ?? [] + ).includes("build_observer_control_event"), + ), + ) + .toBe(true); }); From 83d577dc344e2f1be10f3a11dd49890e2bdc881c Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 15:14:49 +0100 Subject: [PATCH 32/68] Ellipsize the shimmer overlay and center the stop icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shimmer ::before repeats the full data-shimmer-text, so on truncating labels it painted the clipped characters over the base text's ellipsis. The overlay now truncates identically (same font and box, so the ellipsis lands in the same spot). The stop square sized its svg directly, which competed with the button's [&_svg]:size-4 rule and sat the glyph off-center — the size override now lives on the button so class merge resolves it. Co-Authored-By: Claude Fable 5 --- .../src/features/messages/ui/MessageComposerToolbar.tsx | 7 +++++-- desktop/src/shared/styles/globals/animations.css | 7 +++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index 6d03805c64..f7efac52c4 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -322,13 +322,16 @@ export const MessageComposerToolbar = React.memo( {onStopAgent && !isSending ? ( ) : (
{preview ? ( - - ) : null} - {preview ? ( -
- - - - - - {openThreads} open comment{openThreads === 1 ? "" : "s"} - - - -
- {checks && checks.runs.length > 0 ? ( -
    - {dedupeRunKeys(checks.runs).map(({ key, run }) => ( -
  • - - - {run.name} - -
  • - ))} -
- ) : ( - - No checks reported yet. + +
+ + + + + + {openThreads} open comment{openThreads === 1 ? "" : "s"} - )} - -
+ + +
+ {checks && checks.runs.length > 0 ? ( +
    + {dedupeRunKeys(checks.runs).map(({ key, run }) => ( +
  • + + + {run.name} + +
  • + ))} +
+ ) : ( + + No checks reported yet. + + )} + +
+ ) : null} diff --git a/desktop/src/features/chats/ui/ChatsScreen.tsx b/desktop/src/features/chats/ui/ChatsScreen.tsx index 4f8eb68678..4317c766af 100644 --- a/desktop/src/features/chats/ui/ChatsScreen.tsx +++ b/desktop/src/features/chats/ui/ChatsScreen.tsx @@ -310,17 +310,26 @@ export function ChatsScreen({ ); const archiveChatMutation = useArchiveChatMutation(); - // Latest PR link the chat's agent posted — drives the header toggle and - // the top-right work module in the conversation. + // Latest PR link ANY agent in the chat posted — drives the header toggle + // and the top-right work module. Matching only the default agent missed + // PRs from additional agents added to the chat. const agentPullRequestHref = React.useMemo(() => { - const agentPubkey = metadata?.defaultAgentPubkey ?? defaultAgent?.pubkey; - if (!agentPubkey) { + const agentKeys = new Set( + (managedAgentsQuery.data ?? []).map((agent) => + normalizePubkey(agent.pubkey), + ), + ); + const configuredAgent = + metadata?.defaultAgentPubkey ?? defaultAgent?.pubkey; + if (configuredAgent) { + agentKeys.add(normalizePubkey(configuredAgent)); + } + if (agentKeys.size === 0) { return null; } - const agentKey = normalizePubkey(agentPubkey); for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]; - if (normalizePubkey(message.pubkey) !== agentKey) { + if (!agentKeys.has(normalizePubkey(message.pubkey))) { continue; } const preview = extractSupportedLinkPreviews(message.content).find( @@ -331,7 +340,12 @@ export function ChatsScreen({ } } return null; - }, [defaultAgent?.pubkey, messages, metadata?.defaultAgentPubkey]); + }, [ + defaultAgent?.pubkey, + managedAgentsQuery.data, + messages, + metadata?.defaultAgentPubkey, + ]); // null = auto: open once the agent has produced a PR, closed otherwise. // A click stores an explicit preference for the current chat. const [workPanelPreference, setWorkPanelPreference] = React.useState< diff --git a/desktop/src/shared/styles/globals/animations.css b/desktop/src/shared/styles/globals/animations.css index 1befca1fd4..6c7daa537b 100644 --- a/desktop/src/shared/styles/globals/animations.css +++ b/desktop/src/shared/styles/globals/animations.css @@ -263,6 +263,31 @@ } } +/* 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-work-card-in { + animation: none; + } +} + .buzz-poof-layer { inset: 0; overflow: hidden; From 48a9ff97424652b6d7958f426fd5bf59f3a3bc00 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 15:49:49 +0100 Subject: [PATCH 37/68] Track the chat's PR link from any participant, not just agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Author-scoping the work panel's PR scan proved brittle twice over: first it matched only the default agent, then only the viewer's managed agents — an agent someone else added to the chat still slipped through, leaving the panel on the bare branch chip. Any PR link posted in the chat is the chat's work; the scan now simply takes the latest one regardless of author. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/ui/ChatsScreen.tsx | 37 ++++--------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatsScreen.tsx b/desktop/src/features/chats/ui/ChatsScreen.tsx index 4317c766af..6c7ea72c0e 100644 --- a/desktop/src/features/chats/ui/ChatsScreen.tsx +++ b/desktop/src/features/chats/ui/ChatsScreen.tsx @@ -310,42 +310,21 @@ export function ChatsScreen({ ); const archiveChatMutation = useArchiveChatMutation(); - // Latest PR link ANY agent in the chat posted — drives the header toggle - // and the top-right work module. Matching only the default agent missed - // PRs from additional agents added to the chat. + // 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. const agentPullRequestHref = React.useMemo(() => { - const agentKeys = new Set( - (managedAgentsQuery.data ?? []).map((agent) => - normalizePubkey(agent.pubkey), - ), - ); - const configuredAgent = - metadata?.defaultAgentPubkey ?? defaultAgent?.pubkey; - if (configuredAgent) { - agentKeys.add(normalizePubkey(configuredAgent)); - } - if (agentKeys.size === 0) { - return null; - } for (let index = messages.length - 1; index >= 0; index--) { - const message = messages[index]; - if (!agentKeys.has(normalizePubkey(message.pubkey))) { - continue; - } - const preview = extractSupportedLinkPreviews(message.content).find( - (candidate) => candidate.kind === "github-pull-request", - ); + const preview = extractSupportedLinkPreviews( + messages[index].content, + ).find((candidate) => candidate.kind === "github-pull-request"); if (preview) { return preview.href; } } return null; - }, [ - defaultAgent?.pubkey, - managedAgentsQuery.data, - messages, - metadata?.defaultAgentPubkey, - ]); + }, [messages]); // null = auto: open once the agent has produced a PR, closed otherwise. // A click stores an explicit preference for the current chat. const [workPanelPreference, setWorkPanelPreference] = React.useState< From b6af79309f4f8dbc3db0f3f94090202ffcb3541c Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 15:57:50 +0100 Subject: [PATCH 38/68] Render @mentions as chips in chat messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat messages rendered markdown but never passed the mention props, so @mentions stayed plain text. ChatMessageRow now resolves the message's p/mention tags into name→pubkey maps (people vs agent chips, same treatment as channel messages) for every author — own bubbles, other humans, and agents. Mentioned users' pubkeys join the chat's profile batch so a chip resolves even when that user never posted in the chat. Co-Authored-By: Claude Fable 5 --- .../chats/ui/ChatConversationRows.tsx | 32 +++++++++++++++++++ desktop/src/features/chats/ui/ChatsScreen.tsx | 10 ++++++ desktop/tests/e2e/chats-first-message.spec.ts | 21 ++++++++++++ 3 files changed, 63 insertions(+) diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index 9d291597c4..c7c5416da2 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -5,6 +5,10 @@ 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"; @@ -58,6 +62,28 @@ export function ChatMessageRow({ 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 ( @@ -84,12 +110,16 @@ export function ChatMessageRow({ {isAgent ? ( ) : ( )} diff --git a/desktop/src/features/chats/ui/ChatsScreen.tsx b/desktop/src/features/chats/ui/ChatsScreen.tsx index 6c7ea72c0e..855cefdcd6 100644 --- a/desktop/src/features/chats/ui/ChatsScreen.tsx +++ b/desktop/src/features/chats/ui/ChatsScreen.tsx @@ -52,6 +52,7 @@ import { addChannelMembers, getCanvas, setCanvas } from "@/shared/api/tauri"; import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { getMentionTagPubkey } from "@/shared/lib/resolveMentionNames"; import { Button } from "@/shared/ui/button"; type ChatsScreenProps = { @@ -293,6 +294,15 @@ export function ChatsScreen({ 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()), diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 1e9c6d2878..6d8bc4f8c0 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -98,6 +98,7 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { channelName: string; content: string; createdAt?: number; + mentionPubkeys?: string[]; pubkey?: string; }) => unknown; }; @@ -117,10 +118,30 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { createdAt: base + 2, pubkey: pubkey ?? undefined, }); + // A human message with a mention tag: @bob must render as a chip + // (alice's pubkey is a mock profile fixture; bob's resolves via the + // message's p tag). + win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "Hello Fizz, first message", + content: "Loop in @bob for the review.", + createdAt: base + 4, + mentionPubkeys: [ + "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260", + ], + pubkey: + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f", + }); }, { pubkey: fizzPubkey }, ); + // Mentions in chat messages render as chips, same as channels. + await expect( + page.getByLabel("Chat messages").locator("[data-mention]", { + hasText: "bob", + }), + ).toBeVisible({ timeout: 10_000 }); + await expect( page.getByRole("heading", { name: "First message" }), ).toBeVisible({ timeout: 10_000 }); From b74f5e42a803dbff8ea1809d81bd6d181f1ac579 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 16:13:26 +0100 Subject: [PATCH 39/68] New-chat preset cards and activity from every agent in a chat New chat screen: the "Start a chat" center gains channel-intro-style preset cards. Default agent shows who will handle the chat with a picker to swap in another managed agent or a whole team (team personas are created in the chat like template agents, first one becomes the default). Directory shows the selected project's path (or free-chat empty state) and opens the project picker. Invite searches the user directory and pre-adds people as members on create, merged with the @mention flow. Multi-agent activity: the chat transcript and working indicators only read the default agent, so a second agent's turns rendered nothing. The transcript now merges every active managed agent's items by timestamp, live turn ids come from the by-channel store (which now carries turnIds), and the composer stop button cancels every agent working in the chat. Co-Authored-By: Claude Fable 5 --- .../features/agents/activeAgentTurnsStore.ts | 9 +- .../features/agents/ui/useObserverEvents.ts | 56 +++ desktop/src/features/chats/ui/ChatDetail.tsx | 51 +- .../features/chats/ui/ChatStartPresets.tsx | 451 ++++++++++++++++++ .../src/features/chats/ui/QuickStartChat.tsx | 203 +++++++- desktop/tests/e2e/chats-first-message.spec.ts | 32 ++ 6 files changed, 760 insertions(+), 42 deletions(-) create mode 100644 desktop/src/features/chats/ui/ChatStartPresets.tsx diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 020109a011..13252de50e 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -53,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 @@ -468,25 +470,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; } @@ -499,6 +503,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/ui/useObserverEvents.ts b/desktop/src/features/agents/ui/useObserverEvents.ts index 5c767d1876..7a59db48c3 100644 --- a/desktop/src/features/agents/ui/useObserverEvents.ts +++ b/desktop/src/features/agents/ui/useObserverEvents.ts @@ -158,6 +158,62 @@ 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 diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index a6a39f1fff..365d41118b 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -3,12 +3,13 @@ import { useQueryClient } from "@tanstack/react-query"; import { MessageCircle } from "lucide-react"; import { toast } from "sonner"; -import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; +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, - useAgentTranscript, + useAgentsTranscript, } from "@/features/agents/ui/useObserverEvents"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; import { useUpdateChatMetadataMutation } from "@/features/chats/hooks"; @@ -120,22 +121,34 @@ export function ChatDetail({ }: ChatDetailProps) { const queryClient = useQueryClient(); const updateMetadataMutation = useUpdateChatMetadataMutation(); - const hasObserver = defaultAgent ? isManagedAgentActive(defaultAgent) : false; - const activeAgentTurns = useActiveAgentTurns(defaultAgent?.pubkey); + // 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(); + const activeAgentPubkeys = 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(); + }, [defaultAgent, managedAgentsQuery.data]); + 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( - activeAgentTurns + activeChannelTurns .filter((turn) => turn.channelId === chat.id) .flatMap((turn) => turn.turnIds), ), - [activeAgentTurns, chat.id], + [activeChannelTurns, chat.id], ); const isChatTurnActive = activeTurnIds.size > 0; - const transcript = useAgentTranscript(hasObserver, defaultAgent?.pubkey); + const transcript = useAgentsTranscript(hasObserver, activeAgentPubkeys); const scopedTranscript = React.useMemo( () => scopeByChannel(transcript, chat.id), [chat.id, transcript], @@ -161,16 +174,24 @@ export function ChatDetail({ [defaultAgent?.pubkey, messages, scopedTranscript], ); const handleStopAgent = React.useCallback(() => { - if (!defaultAgent?.pubkey) { - return; - } - cancelManagedAgentTurn(defaultAgent.pubkey, chat.id).catch( - (error: unknown) => { + // 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"); - }, - ); - }, [chat.id, defaultAgent?.pubkey]); + }); + } + }, [activeChannelTurns, chat.id, defaultAgent?.pubkey]); const selectedProject = React.useMemo( () => chatProjectForMetadata(metadata), [metadata], diff --git a/desktop/src/features/chats/ui/ChatStartPresets.tsx b/desktop/src/features/chats/ui/ChatStartPresets.tsx new file mode 100644 index 0000000000..88bdaea52c --- /dev/null +++ b/desktop/src/features/chats/ui/ChatStartPresets.tsx @@ -0,0 +1,451 @@ +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 type { + AgentTeam, + ManagedAgent, + UserSearchResult, +} from "@/shared/api/types"; +import { searchUsers } from "@/shared/api/tauri"; +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(""); + const [results, setResults] = React.useState([]); + const [isSearching, setIsSearching] = React.useState(false); + + React.useEffect(() => { + const trimmed = query.trim(); + if (!open || trimmed.length === 0) { + setResults([]); + setIsSearching(false); + return; + } + setIsSearching(true); + let cancelled = false; + const handle = window.setTimeout(() => { + searchUsers(trimmed) + .then((users) => { + if (!cancelled) { + setResults(users); + } + }) + .catch(() => { + if (!cancelled) { + setResults([]); + } + }) + .finally(() => { + if (!cancelled) { + setIsSearching(false); + } + }); + }, 250); + return () => { + cancelled = true; + window.clearTimeout(handle); + }; + }, [open, query]); + + 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/QuickStartChat.tsx b/desktop/src/features/chats/ui/QuickStartChat.tsx index 26b4b9c3b7..40271c14c9 100644 --- a/desktop/src/features/chats/ui/QuickStartChat.tsx +++ b/desktop/src/features/chats/ui/QuickStartChat.tsx @@ -15,8 +15,28 @@ 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, @@ -32,10 +52,18 @@ import { import { ChatProjectDialog } from "@/features/chats/ui/ChatProjectDialog"; import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; -import { ensureWelcomeGuideAgentInChannel } from "@/features/onboarding/welcomeGuide"; +import { + ensureWelcomeGuideAgentInChannel, + WELCOME_GUIDE_AGENT_NAME, +} from "@/features/onboarding/welcomeGuide"; import { useIdentityQuery } from "@/shared/api/hooks"; import { addChannelMembers, sendChannelMessage } from "@/shared/api/tauri"; -import type { Channel, ChannelTemplate } from "@/shared/api/types"; +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"; @@ -92,6 +120,10 @@ export function QuickStartChat({ 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(); @@ -100,6 +132,14 @@ export function QuickStartChat({ 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], + ); const templates = templatesQuery.data ?? []; const allProjects = projects; const selectedProject = @@ -138,6 +178,58 @@ export function QuickStartChat({ [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, @@ -172,7 +264,21 @@ export function QuickStartChat({ await applyCanvas(templateId, chat.id, title, projectCanvasContext); void applyAgents(templateId, chat.id); - const agent = await ensureWelcomeGuideAgentInChannel(chat.id, relayUrl); + // 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, @@ -204,15 +310,21 @@ export function QuickStartChat({ }); } - const memberMentionPubkeys = nonAgentMentionPubkeys({ - defaultAgentPubkey: agent.pubkey, - identityPubkey: identityQuery.data?.pubkey, - managedAgentPubkeys: - managedAgentsQuery.data?.map( - (managedAgent) => managedAgent.pubkey, - ) ?? [], - mentionPubkeys, - }); + 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, @@ -266,9 +378,12 @@ export function QuickStartChat({ } }, [ + agentPreset, applyAgents, applyCanvas, createChatMutation, + createTeamAgents, + invited, identityQuery.data?.pubkey, isCreating, managedAgentsQuery.data, @@ -303,12 +418,45 @@ export function QuickStartChat({ transparentChrome /> -
-
-

Start a chat

-

- Describe a task or ask a question. -

+
+
+
+

+ Start a chat +

+

+ Describe a task or ask a question. +

+
+ + } + /> + } + teams={usableTeams} + />
@@ -364,6 +512,7 @@ export function ProjectPicker({ projects, selectedProject, templates, + trigger, }: { isNoProjectSelected: boolean; onCreateProject: (project: ChatProject) => void; @@ -371,6 +520,8 @@ export function ProjectPicker({ 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(""); @@ -392,13 +543,15 @@ export function ProjectPicker({ <> - - - - {selectedProject?.name || - (isNoProjectSelected ? "No project" : "Project")} - - + {trigger ?? ( + + + + {selectedProject?.name || + (isNoProjectSelected ? "No project" : "Project")} + + + )}
diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 6d8bc4f8c0..dc15e42dc3 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -196,6 +196,38 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { await page.screenshot({ path: "test-results/agent-pr-card.png" }); }); +test("new chat screen shows agent, directory, and invite preset cards", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/#/chats"); + + const agentCard = page.getByTestId("chat-preset-agent"); + const directoryCard = page.getByTestId("chat-preset-directory"); + const inviteCard = page.getByTestId("chat-preset-invite"); + await expect(agentCard).toBeVisible(); + await expect(agentCard).toContainText("Fizz"); + await expect(directoryCard).toBeVisible(); + await expect(inviteCard).toBeVisible(); + await expect(inviteCard).toContainText("Invite"); + + // Agent picker lists the default agent option. + await agentCard.click(); + await expect( + page.getByRole("dialog").getByText("Default agent"), + ).toBeVisible(); + await page.keyboard.press("Escape"); + + // Invite picker searches the user directory and stores a selection. + await inviteCard.click(); + await page.getByPlaceholder("Search people").fill("alice"); + await page.getByRole("dialog").getByText("alice").click(); + await page.keyboard.press("Escape"); + await expect(inviteCard).toContainText("1 invited"); + + await page.screenshot({ path: "test-results/chat-start-presets.png" }); +}); + test("sidebar chat title shimmers while the agent has an active turn", async ({ page, }) => { From 141adb789272404495ad5b4d6f03bc8e3d48fe28 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 16:21:21 +0100 Subject: [PATCH 40/68] Preset card polish and PR discovery from the branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preset cards: taller cards with breathing room between the icon and the text block; the agent picker no longer lists the welcome guide's managed instance next to the Default agent row (the duplicate Fizz); the invite search now goes through useUserSearchQuery — the same normalized, cached path the channel invite uses — instead of a raw searchUsers call whose un-lowercased query could miss on relay search. Work panel: when no PR link was ever posted in the chat, the panel now discovers the pull request from the branch itself — a new find_github_pr_for_branch command reads the project's origin remote (ssh or https GitHub URLs), asks the pulls API for the branch head, and falls back to scanning open PRs for fork-headed branches. The panel runs this only while it has a branch + project directory and no posted link, polling slowly so a PR opened mid-conversation surfaces on its own. Co-Authored-By: Claude Fable 5 --- .../src-tauri/src/commands/link_preview.rs | 123 ++++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + desktop/src/features/chats/ui/ChatDetail.tsx | 1 + .../features/chats/ui/ChatStartPresets.tsx | 56 ++------ .../src/features/chats/ui/ChatWorkPanel.tsx | 33 ++++- .../src/features/chats/ui/QuickStartChat.tsx | 14 +- desktop/src/shared/lib/githubPullRequest.ts | 23 ++++ desktop/src/testing/e2eBridge.ts | 4 + 8 files changed, 205 insertions(+), 50 deletions(-) diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 1da7734e95..e2d78f3099 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -293,6 +293,129 @@ pub async fn fetch_github_pr_comment_state( 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 = reqwest::Client::builder() + .pool_idle_timeout(Duration::from_secs(10)) + .pool_max_idle_per_host(1) + .build() + .map_err(|error| format!("github client failed: {error}"))?; + 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 { ["GITHUB_TOKEN", "GH_TOKEN"].iter().find_map(|name| { std::env::var(name) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index fa42960c7e..eb6c233162 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -462,6 +462,7 @@ pub fn run() { 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, diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index 365d41118b..0338d61875 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -679,6 +679,7 @@ export function ChatDetail({ onAutomationPrompt={(content) => void onSend(content, [])} open={showWorkPanel} prHref={workPanelHref} + projectPath={metadata?.projectPath ?? selectedProject?.path ?? null} />
diff --git a/desktop/src/features/chats/ui/ChatStartPresets.tsx b/desktop/src/features/chats/ui/ChatStartPresets.tsx index 88bdaea52c..f67bc25fc6 100644 --- a/desktop/src/features/chats/ui/ChatStartPresets.tsx +++ b/desktop/src/features/chats/ui/ChatStartPresets.tsx @@ -11,12 +11,8 @@ import { } from "lucide-react"; import type { ChatProject } from "@/features/chats/lib/chatSetup"; -import type { - AgentTeam, - ManagedAgent, - UserSearchResult, -} from "@/shared/api/types"; -import { searchUsers } from "@/shared/api/tauri"; +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"; @@ -106,7 +102,7 @@ export function PresetCard({ }: PresetCardProps) { return (
+ (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, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index cd22a7f317..1b4f02d4b7 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8903,6 +8903,10 @@ export function maybeInstallE2eTauriMocks() { }; case "fetch_github_pr_comment_state": return { openThreads: 2 }; + case "find_github_pr_for_branch": + // Specs drive the work panel through posted links; branch discovery + // resolves to nothing in mocks. + return null; case "build_observer_control_event": { // Specs assert the invocation (via __BUZZ_E2E_COMMANDS__); the event // itself only needs to be publishable over the mock websocket. From 67bdfcfc0b2cf51ce75d5e0df6f4d539b1ca82fc Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 16:26:14 +0100 Subject: [PATCH 41/68] Tint link chips inside own message bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone link-chip treatment (opaque muted fill, provider-colored icon tile) read as a gray slab inside the user's primary-colored bubble — most visible on the automation prompts carrying a PR link. Chips in own bubbles now derive everything from currentColor: translucent fill, border, and icon tile, so they read as part of the bubble in both themes. Co-Authored-By: Claude Fable 5 --- .../chats/ui/ChatConversationRows.tsx | 5 ++++- .../src/shared/styles/globals/markdown.css | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index c7c5416da2..ef301be53d 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -117,7 +117,10 @@ export function ChatMessageRow({ mentionPubkeysByName={mentionPubkeysByName} /> ) : ( - + code { From 38c9ab7ab5fa70e673c82c9cf1722eea73438ca3 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 16:36:37 +0100 Subject: [PATCH 42/68] Offer Side conversation on thread messages The action reached the main timeline's rows but was never threaded into MessageThreadPanel, so messages inside a thread had no handler and the menu item silently didn't render. The panel now accepts onStartSideConversation and passes it to both the thread head and reply rows; ChannelPane forwards the same archived-gated handler the main timeline uses. Co-Authored-By: Claude Fable 5 --- desktop/src/features/channels/ui/ChannelPane.tsx | 1 + desktop/src/features/messages/ui/MessageThreadPanel.tsx | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 9c94da465b..72092958bc 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -775,6 +775,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onExpandReplies={onExpandThreadReplies} onSelectReplyTarget={onSelectThreadReplyTarget} onSend={onSendThreadReply} + onStartSideConversation={onStartSideConversation} onScrollTargetResolved={onThreadScrollTargetResolved} onToggleReaction={onToggleReaction} onUnfollowThread={onUnfollowThread} 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} From e63099c09330cf166a0acae6c787d9723f1e73bc Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 06:33:18 +0100 Subject: [PATCH 43/68] Weight chat conversation text at medium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Message content in chats — bubbles and agent replies — steps up to font-medium so the conversation reads a notch heavier than the muted marker plumbing around it. Markers keep their current weight. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/ui/ChatConversationRows.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index ef301be53d..63335f7113 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -111,7 +111,7 @@ export function ChatMessageRow({ Date: Sun, 5 Jul 2026 06:38:03 +0100 Subject: [PATCH 44/68] Hide automation prompts from the chat timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-fix-CI and address-comments nudges are sent as user messages so the agent hears them through the normal pipeline — but rendering them as the user's own bubbles broke the illusion. They now carry an ["automation", "work-panel"] tag (riding the outgoing tag path the composer already uses) and the timeline renders only their anchored activity: the agent's turn markers and reply appear with no visible prompt, so armed automation reads as ambient. Co-Authored-By: Claude Fable 5 --- .../features/chats/lib/chatWorkAutomation.ts | 11 +++++++ desktop/src/features/chats/ui/ChatDetail.tsx | 14 +++++++-- desktop/tests/e2e/chats-first-message.spec.ts | 31 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts index a8e76af32d..d9c7535019 100644 --- a/desktop/src/features/chats/lib/chatWorkAutomation.ts +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -7,6 +7,17 @@ import * as React from "react"; const STORAGE_PREFIX = "buzz:chat-work-automation:v1"; const STORAGE_EVENT = "buzz:chat-work-automation-changed"; +/** + * Tag attached to automation-generated prompts (auto-fix CI, address + * comments). The message still reaches the agent like any user message, but + * the chat timeline renders only its activity — not the message bubble — so + * armed automation feels ambient instead of ventriloquized. + */ +export const CHAT_AUTOMATION_TAG: [string, string] = [ + "automation", + "work-panel", +]; + export type ChatWorkAutomation = { autoFixCi: boolean; addressComments: boolean; diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index 0338d61875..0fcf8c4f56 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -27,6 +27,7 @@ import { NO_PROJECT_SELECTION_ID, } from "@/features/chats/lib/chatSetup"; import { ChatActivityTranscript } from "@/features/chats/ui/ChatActivityTranscript"; +import { CHAT_AUTOMATION_TAG } from "@/features/chats/lib/chatWorkAutomation"; import { deriveBranchFromAgentMessages, deriveChatWorkBranch, @@ -562,6 +563,13 @@ export function ChatDetail({ "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) === @@ -579,7 +587,7 @@ export function ChatDetail({ )} messageId={message.id} > - {isContextMessage ? ( + {isAutomationMessage ? null : isContextMessage ? ( ) : ( void onSend(content, [])} + onAutomationPrompt={(content) => + void onSend(content, [], [CHAT_AUTOMATION_TAG]) + } open={showWorkPanel} prHref={workPanelHref} projectPath={metadata?.projectPath ?? selectedProject?.path ?? null} diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index dc15e42dc3..0c4935de88 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -187,6 +187,37 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { await expect(page.getByTestId("automation-auto-fix-ci")).toBeVisible(); await expect(page.getByTestId("automation-address-comments")).toBeVisible(); + // Arming address-comments fires the automation prompt (2 open threads in + // the mock). The message goes out tagged — but no bubble renders: the + // automation stays invisible in the timeline. + await page.getByTestId("automation-address-comments").click(); + await expect + .poll(() => + page.evaluate(() => + ( + ( + window as Window & { + __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ + command: string; + payload?: { content?: string; mediaTags?: string[][] }; + }>; + } + ).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [] + ).some( + (entry) => + entry.command === "send_channel_message" && + entry.payload?.content?.includes("unanswered review comments") && + entry.payload?.mediaTags?.some( + (tag) => tag[0] === "automation" && tag[1] === "work-panel", + ), + ), + ), + ) + .toBe(true); + await expect( + page.getByLabel("Chat messages").getByText("unanswered review comments"), + ).toHaveCount(0); + // The header's PR button toggles the panel. await page.getByTestId("toggle-work-panel").click(); await expect(workPanel).not.toBeVisible(); From 5cf7a86e5e27455772c3d68a2f7a3bc7d5ebacc7 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 06:45:59 +0100 Subject: [PATCH 45/68] Hold agent activation in its loading state until the agent responds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking Activate reverted to idle (with a success toast) as soon as the start API returned — but a launched process is not a responding agent, so the card lingered and read as a failed activation. The card's button now stays in its loading state from click until the chat's turn actually starts (the card unmounts then), with a 60s give-up window, and the copy switches to "Starting {agent}… It will pick up your message as soon as it connects." The premature success toast is gone — the agent visibly starting work is the confirmation. Co-Authored-By: Claude Fable 5 --- .../chats/ui/ChatConversationRows.tsx | 9 ++++-- desktop/src/features/chats/ui/ChatDetail.tsx | 30 +++++++++++++++++-- desktop/src/features/chats/ui/ChatsScreen.tsx | 8 ++--- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index 63335f7113..3bae8289b1 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -232,11 +232,14 @@ export function AgentActivationCard({

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

- Your message was sent, but this agent is not active in this - chat yet. + {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 index 0fcf8c4f56..d1c8c75891 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -470,6 +470,30 @@ export function ChatDetail({ // message, and start its turn. Without this grace window the card re-shows // ~1s after activation and reads as "activation didn't work". const AGENT_ACTIVATION_GRACE_MS = 20_000; + // "Activated" isn't done until the agent responds: the card's button holds + // its loading state from click until this chat has a live turn (the card + // unmounts then) or the give-up window expires — the start API returning + // only means the process launched, not that it's connected and replaying + // the pending message. + const ACTIVATION_PENDING_MS = 60_000; + const [isActivationPending, setIsActivationPending] = React.useState(false); + const handleActivateAgent = React.useCallback(() => { + setIsActivationPending(true); + onActivateAgent(); + }, [onActivateAgent]); + React.useEffect(() => { + if (!isActivationPending) { + return; + } + if (isChatTurnActive) { + setIsActivationPending(false); + return; + } + const timeout = window.setTimeout(() => { + setIsActivationPending(false); + }, ACTIVATION_PENDING_MS); + return () => window.clearTimeout(timeout); + }, [isActivationPending, isChatTurnActive]); const [activationGraceUntil, setActivationGraceUntil] = React.useState(0); const wasActivatingRef = React.useRef(false); React.useEffect(() => { @@ -620,8 +644,10 @@ export function ChatDetail({ > ) : null} diff --git a/desktop/src/features/chats/ui/ChatsScreen.tsx b/desktop/src/features/chats/ui/ChatsScreen.tsx index 855cefdcd6..b2af641d5f 100644 --- a/desktop/src/features/chats/ui/ChatsScreen.tsx +++ b/desktop/src/features/chats/ui/ChatsScreen.tsx @@ -516,10 +516,10 @@ export function ChatsScreen({ defaultAgent.status !== "running" && defaultAgent.status !== "deployed" ) { - const updatedAgent = await startManagedAgentMutation.mutateAsync( - defaultAgent.pubkey, - ); - toast.success(`${updatedAgent.name || defaultAgent.name} activated`); + // 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`); From 21faa1722983ce0f43ea096c06073cc87f567ac1 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 06:48:06 +0100 Subject: [PATCH 46/68] Match transcript message weight to conversation text The agent's in-transcript messages (the self-talk that renders between markers) and mid-turn user prompts step up to font-medium, matching the persisted conversation rows. Marker rows and their expanded details keep their lighter treatment. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/ui/ChatActivityTranscript.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx index e63d292574..fd26c9b433 100644 --- a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx +++ b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx @@ -488,7 +488,7 @@ function ChatTranscriptMessageRow({ {isUser ? ( @@ -496,7 +496,7 @@ function ChatTranscriptMessageRow({ ) : ( )} From f682796d43b391c7ffc4efdea7f232703405c932 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 06:55:38 +0100 Subject: [PATCH 47/68] Name chats the way branches are named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat-title side prompt asked for a "short title", which drifts toward wordy summaries; the agent's git branch names for the same work are consistently terser. The prompt now asks the model to name the conversation exactly as it would name a branch for the task — the same few concrete words — written with spaces instead of dashes. When a model answers with the literal slug anyway, the sanitizer converts a single dashed/underscored/slashed token into spaced words with a leading capital. Co-Authored-By: Claude Fable 5 --- crates/buzz-acp/src/pool.rs | 46 +++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 4f8eeb0142..c730628a7c 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -3029,9 +3029,12 @@ async fn generate_chat_title( request_text: &str, ) -> Option { let prompt = format!( - "You are titling a chat conversation. Reply with ONLY a short title \ - (3-6 words, no quotes, no trailing punctuation) that names what the \ - conversation is trying to accomplish.\n\nOpening message:\n{request_text}" + "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 { @@ -3109,10 +3112,24 @@ fn sanitize_chat_title(raw: &str) -> Option { .unwrap_or(cleaned.len()); cleaned.truncate(last_space.unwrap_or(hard_cut)); } - let cleaned = cleaned + 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; } @@ -3539,6 +3556,27 @@ mod tests { 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); From d2d26bc07c8503865e28ed3850b992c079f3aea9 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 07:02:52 +0100 Subject: [PATCH 48/68] Reject placeholder tokens when parsing branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A message quoting a template command — `git checkout -b ` — parsed the literal "" as the chat's branch and the work panel chip displayed it. Every command-parsed candidate now has to look like a real git ref (alphanumeric start, ref charset, not a sha); the prose patterns were already restricted. Co-Authored-By: Claude Fable 5 --- .../chats/lib/chatWorkBranch.test.mjs | 19 +++++++++++++++++++ .../src/features/chats/lib/chatWorkBranch.ts | 18 ++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/chats/lib/chatWorkBranch.test.mjs b/desktop/src/features/chats/lib/chatWorkBranch.test.mjs index cecd8b5457..e92de836a3 100644 --- a/desktop/src/features/chats/lib/chatWorkBranch.test.mjs +++ b/desktop/src/features/chats/lib/chatWorkBranch.test.mjs @@ -141,3 +141,22 @@ test("non-agent messages and branchless text derive nothing", () => { ); assert.equal(deriveBranchFromAgentMessages([], null), null); }); + +test("placeholder tokens in template commands never parse as branches", () => { + assert.equal(parseBranchFromCommand("git checkout -b "), null); + assert.equal(parseBranchFromCommand("git switch "), null); + assert.equal(parseBranchFromCommand("git worktree add ../wt "), null); + assert.equal(parseBranchFromCommand("git worktree add ../"), null); + assert.equal( + deriveBranchFromAgentMessages( + [ + { + pubkey: "cd".repeat(32), + content: "Run `git checkout -b ` to start.", + }, + ], + "cd".repeat(32), + ), + null, + ); +}); diff --git a/desktop/src/features/chats/lib/chatWorkBranch.ts b/desktop/src/features/chats/lib/chatWorkBranch.ts index 1226b7d278..cae1b2a18e 100644 --- a/desktop/src/features/chats/lib/chatWorkBranch.ts +++ b/desktop/src/features/chats/lib/chatWorkBranch.ts @@ -123,12 +123,13 @@ function parseWorktreeAdd(args: string[]): string | null { if (positional.length >= 2) { // `git worktree add ` — a commit-ish second arg (sha) // isn't a branch name worth showing. - return looksLikeSha(positional[1]) ? null : positional[1]; + return isPlausibleBranchName(positional[1]) ? positional[1] : null; } if (positional.length === 1) { // `git worktree add ` creates a branch named after the basename. const parts = positional[0].split("/").filter(Boolean); - return parts[parts.length - 1] || null; + const basename = parts[parts.length - 1] ?? ""; + return isPlausibleBranchName(basename) ? basename : null; } return null; } @@ -145,7 +146,7 @@ function parseCheckoutOrSwitch(args: string[]): string | null { return null; } const positional = args.filter((token) => !token.startsWith("-")); - if (positional.length !== 1 || looksLikeSha(positional[0])) { + if (positional.length !== 1 || !isPlausibleBranchName(positional[0])) { return null; } return positional[0]; @@ -155,7 +156,7 @@ function valueOfFlag(args: string[], flags: string[]): string | null { for (let index = 0; index < args.length; index += 1) { if (flags.includes(args[index])) { const value = args[index + 1]; - if (value && !value.startsWith("-")) { + if (value && isPlausibleBranchName(value)) { return value; } } @@ -163,6 +164,15 @@ function valueOfFlag(args: string[], flags: string[]): string | null { return null; } +/** + * Real git ref charset, no placeholders: template text like + * `git checkout -b ` in an explanatory message must never surface + * "" as the chip's branch. + */ +function isPlausibleBranchName(token: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(token) && !looksLikeSha(token); +} + function looksLikeSha(token: string): boolean { return /^[0-9a-f]{7,40}$/i.test(token); } From 527330a093a4d656db0c8406a89fa7445c34c1ad Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 07:10:32 +0100 Subject: [PATCH 49/68] Keep the PR monitor live: gh token fallback, honest errors, poll gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The monitor went stale-empty overnight because every failure path compounded: the app usually launches without GITHUB_TOKEN in its env, so all GitHub calls ran anonymous (60 req/h) while the panel polls ~180 req/h — rate-limited within minutes — and non-success responses returned Ok(None), which the UI rendered as a confident "No checks / 0 open comments" instead of an error. Three fixes: ambient_github_token falls back to `gh auth token` (resolved once per process) so the desktop uses the CLI credential that's actually on the machine; GitHub commands now return Err on non-404 failures so React Query keeps the last good snapshot and retries instead of overwriting it with nothing; and polling pauses entirely while the drawer is closed — unless automation is armed, which keeps watching in the background. Co-Authored-By: Claude Fable 5 --- .../src-tauri/src/commands/link_preview.rs | 61 ++++++++++++++++--- .../src/features/chats/ui/ChatWorkPanel.tsx | 17 ++++-- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index e2d78f3099..ce255c2f5e 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -103,9 +103,14 @@ pub async fn fetch_github_pull_request( .send() .await .map_err(|error| format!("github request failed: {error}"))?; - if !response.status().is_success() { + 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() @@ -167,9 +172,14 @@ pub async fn fetch_github_check_summary( .send() .await .map_err(|error| format!("github request failed: {error}"))?; - if !response.status().is_success() { + 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() @@ -249,9 +259,12 @@ pub async fn fetch_github_pr_comment_state( .send() .await .map_err(|error| format!("github request failed: {error}"))?; - if !pr_response.status().is_success() { + 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 @@ -264,9 +277,15 @@ pub async fn fetch_github_pr_comment_state( .send() .await .map_err(|error| format!("github request failed: {error}"))?; - if !comments_response.status().is_success() { + 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 @@ -417,12 +436,34 @@ fn parse_github_remote(remote: &str) -> Option<(String, String)> { } fn ambient_github_token() -> Option { - ["GITHUB_TOKEN", "GH_TOKEN"].iter().find_map(|name| { - std::env::var(name) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - }) + 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 { diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 2c1d688a3e..23f2d774d3 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -55,23 +55,32 @@ export function ChatWorkPanel({ 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). + // 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; const discoveredPrQuery = useGithubPrForBranchQuery( - prHref ? null : projectPath, + monitorActive && !prHref ? projectPath : null, branch, ); const effectiveHref = prHref ?? discoveredPrQuery.data ?? null; const preview = effectiveHref ? parseSupportedLinkPreview(effectiveHref) : null; - const ref = effectiveHref ? parseGithubPullRequestRef(effectiveHref) : null; + const parsedRef = effectiveHref + ? parseGithubPullRequestRef(effectiveHref) + : null; + const ref = monitorActive ? parsedRef : null; const prQuery = useGithubPullRequestQuery(ref); const pr = prQuery.data ?? null; const checksQuery = useGithubCheckSummaryQuery(ref, pr?.headSha); const checks = checksQuery.data ?? null; const commentStateQuery = useGithubCommentStateQuery(ref); const openThreads = commentStateQuery.data?.openThreads ?? 0; - const automation = useChatWorkAutomation(chatId); // 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. const currentBranch = branch?.trim() || pr?.headRef?.trim() || null; From fc43365ffbe8d379e6d1c5ee1d672bc2588bf9d6 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 07:17:08 +0100 Subject: [PATCH 50/68] Never render reaction events as chat bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat timeline filtered out only system messages, but the channel query also delivers reactions/edits/deletions — so an agent's 👀/💬 acknowledgment reactions (kind 7, from harnesses predating the chat gating) rendered as tiny emoji message bubbles. The timeline and the solo-layout participant count now admit only the true message kinds, suppressing the emoji regardless of which harness build the agent runs. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/ui/ChatDetail.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index d1c8c75891..b434049a93 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -52,7 +52,7 @@ import type { ManagedAgent, RelayEvent, } from "@/shared/api/types"; -import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; +import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { @@ -281,7 +281,14 @@ export function ChatDetail({ const visibleMessages = React.useMemo( () => messages.filter((message) => { - if (message.kind === KIND_SYSTEM_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; } const isAgent = @@ -309,7 +316,13 @@ export function ChatDetail({ const showAgentIdentity = React.useMemo(() => { const others = new Set(); for (const message of messages) { - if (message.kind === KIND_SYSTEM_MESSAGE) { + // 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); From f3a035c424132b9d728df49e1c92c4cd65c66045 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 07:20:02 +0100 Subject: [PATCH 51/68] Animate the work panel only for new information MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch swap and the card pop-in replayed on every visit to a chat: the panel mounts on "No current branch", async PR data resolves a beat later, and the change animated even though nothing was new. The panel now remembers the last branch/PR shown per chat — revisits render the cached value statically from the first frame, the pop-in decision is latched per mount (recording the href as seen must not strip the class mid-animation), and the branch text is keyed by chat so switching chats never animates one chat's branch into another's. A genuinely new branch or PR still animates exactly once. Co-Authored-By: Claude Fable 5 --- .../src/features/chats/ui/ChatWorkPanel.tsx | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 23f2d774d3..80b0ee2749 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -30,6 +30,13 @@ 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(); + /** * 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. @@ -83,7 +90,35 @@ export function ChatWorkPanel({ 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. - const currentBranch = branch?.trim() || pr?.headRef?.trim() || null; + // 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]); // Automation: prompt the agent on CI failure / newly-open review threads. // Watermarks in storage keep this to one nudge per failing sha and per @@ -145,6 +180,10 @@ export function ChatWorkPanel({ "min-w-0", currentBranch ? "font-mono" : "text-muted-foreground", )} + // Keyed by chat: switching chats remounts the text statically + // (first render never animates) — only an in-place branch + // change for THIS chat plays the swap. + key={chatId} text={currentBranch ?? "No current branch"} /> @@ -152,7 +191,10 @@ export function ChatWorkPanel({ // Keyed by href so a NEW pull request re-runs the pop-in, not // just the first one.
From 86cab382ae2c13cd289657d01fe0f78af5abc31a Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 07:25:35 +0100 Subject: [PATCH 52/68] Re-nudge stalled automation and add manual Run now overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The automation watermarks made each nudge one-shot: fired against a failing sha (or a comment count) exactly once, with no retry if the agent was stopped when the prompt landed or the send never took — leaving red CI and open comments with no agent working and no way to re-trigger short of unchecking and re-checking. Two escapes: while a condition persists (CI still failing / comments still open) and NO turn is running in the chat, the armed automation re-nudges after a 15-minute cooldown (timestamps join the stored watermarks); and each automation row shows a "Run now" button whenever its condition is active and the agent is idle, which fires the same prompt immediately, watermarks aside. Co-Authored-By: Claude Fable 5 --- .../features/chats/lib/chatWorkAutomation.ts | 12 ++ desktop/src/features/chats/ui/ChatDetail.tsx | 1 + .../src/features/chats/ui/ChatWorkPanel.tsx | 167 ++++++++++++------ desktop/tests/e2e/chats-first-message.spec.ts | 4 + 4 files changed, 133 insertions(+), 51 deletions(-) diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts index d9c7535019..0adc0992ee 100644 --- a/desktop/src/features/chats/lib/chatWorkAutomation.ts +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -25,6 +25,10 @@ export type ChatWorkAutomation = { lastCiNudgeSha: string | null; /** Comment total at the last address-comments nudge. */ lastCommentNudgeCount: number | null; + /** Epoch ms of the last CI nudge — drives the persistent-failure re-nudge. */ + lastCiNudgeAt: number | null; + /** Epoch ms of the last comment nudge. */ + lastCommentNudgeAt: number | null; }; const DEFAULTS: ChatWorkAutomation = { @@ -32,6 +36,8 @@ const DEFAULTS: ChatWorkAutomation = { addressComments: false, lastCiNudgeSha: null, lastCommentNudgeCount: null, + lastCiNudgeAt: null, + lastCommentNudgeAt: null, }; function storageKey(chatId: string) { @@ -59,6 +65,12 @@ export function readChatWorkAutomation(chatId: string): ChatWorkAutomation { typeof parsed.lastCommentNudgeCount === "number" ? parsed.lastCommentNudgeCount : null, + lastCiNudgeAt: + typeof parsed.lastCiNudgeAt === "number" ? parsed.lastCiNudgeAt : null, + lastCommentNudgeAt: + typeof parsed.lastCommentNudgeAt === "number" + ? parsed.lastCommentNudgeAt + : null, }; } catch { return DEFAULTS; diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index b434049a93..9544e782d1 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -723,6 +723,7 @@ export function ChatDetail({ void onSend(content, [], [CHAT_AUTOMATION_TAG]) } diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 80b0ee2749..50106b03a9 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -37,6 +37,12 @@ const CHIP_CLASS = 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; + /** * 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. @@ -47,6 +53,7 @@ const lastShownPrByChat = new Map(); export function ChatWorkPanel({ branch = null, chatId, + isTurnActive = false, onAutomationPrompt, open = true, prHref, @@ -55,6 +62,8 @@ export function ChatWorkPanel({ /** 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) => void; open?: boolean; prHref?: string | null; @@ -120,43 +129,75 @@ export function ChatWorkPanel({ } }, [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.`, + ); + }, [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.`, + ); + }, [chatId, effectiveHref, onAutomationPrompt, openThreads]); + // Automation: prompt the agent on CI failure / newly-open review threads. - // Watermarks in storage keep this to one nudge per failing sha and per - // rise in open threads; the thread watermark re-arms once everything has - // been replied to (count back at zero). + // 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 && - checks && - checks.failed > 0 && - checks.pending === 0 && - automation.lastCiNudgeSha !== pr.headSha - ) { - updateChatWorkAutomation(chatId, { lastCiNudgeSha: pr.headSha }); - onAutomationPrompt( - `CI is failing on ${effectiveHref} (${checks.failed} of ${checks.total} checks). Investigate the failures and push fixes until the checks pass.`, - ); + 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 > threadWatermark) { - updateChatWorkAutomation(chatId, { lastCommentNudgeCount: openThreads }); - 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.`, - ); + } 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 ( @@ -234,38 +275,62 @@ export function ChatWorkPanel({ )}
diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 0c4935de88..a45de1dfbe 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -218,6 +218,10 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { page.getByLabel("Chat messages").getByText("unanswered review comments"), ).toHaveCount(0); + // Manual overrides: open comments expose "Run now"; green CI does not. + await expect(page.getByTestId("automation-run-comments-now")).toBeVisible(); + await expect(page.getByTestId("automation-run-ci-now")).toHaveCount(0); + // The header's PR button toggles the panel. await page.getByTestId("toggle-work-panel").click(); await expect(workPanel).not.toBeVisible(); From 6047a1153799c67bcb9c509f6a892bbde9f1cc04 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 07:31:40 +0100 Subject: [PATCH 53/68] Make mention chips legible inside own message bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mention chip's standalone palette is primary text on a primary tint — inside the primary-colored own bubble that disappears entirely, rendering mentioned names as blank gaps. Chips in own bubbles now derive from currentColor like the link chips (translucent fill, inherited text), staying legible on any bubble in both themes. The agent-mention icon already draws with currentColor, so it follows. Co-Authored-By: Claude Fable 5 --- desktop/src/shared/styles/globals/markdown.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 8cdb3fe67c..2f7ef36a0d 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -91,6 +91,15 @@ 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; +} + .message-markdown .mention-chip, .message-markdown .inline-code-chip, .message-markdown :not(pre) > code { From ed160c68697b082144d65fe0b540a168b8a3ddbf Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 07:36:30 +0100 Subject: [PATCH 54/68] Efficiency pass over the chat features Four fixes from a branch-wide review of the hot paths: - useChatWorkAutomation content-compares its snapshot: a fresh object per storage event re-ran every consumer effect and re-rendered the panel even when nothing changed. - ChatMessageRow and ChatActivityTranscript are memoized: ChatDetail re-renders on every observer frame during a live turn, and every persisted message re-rendered its whole Markdown tree each time. - The active-agent pubkey list is key-stabilized: the managed-agents 30s refetch minted a fresh array identity that resubscribed the transcript store with an unchanged set. - GitHub API commands share one reqwest client: the PR monitor polls on short intervals and built a new connection pool + TLS session cache per call. Co-Authored-By: Claude Fable 5 --- .../src-tauri/src/commands/link_preview.rs | 41 ++++----- .../features/chats/lib/chatWorkAutomation.ts | 15 +++- .../chats/ui/ChatActivityTranscript.tsx | 85 ++++++++++--------- .../chats/ui/ChatConversationRows.tsx | 7 +- desktop/src/features/chats/ui/ChatDetail.tsx | 11 ++- 5 files changed, 94 insertions(+), 65 deletions(-) diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index ce255c2f5e..47e8106740 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -11,6 +11,23 @@ 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")] @@ -82,11 +99,7 @@ pub async fn fetch_github_pull_request( return Err("invalid GitHub repository reference".to_string()); } - let client = reqwest::Client::builder() - .pool_idle_timeout(Duration::from_secs(10)) - .pool_max_idle_per_host(1) - .build() - .map_err(|error| format!("github client failed: {error}"))?; + let client = github_client()?; let url = format!("https://api.github.com/repos/{owner}/{repo}/pulls/{number}"); let mut request = client @@ -149,11 +162,7 @@ pub async fn fetch_github_check_summary( return Err("invalid GitHub check reference".to_string()); } - let client = reqwest::Client::builder() - .pool_idle_timeout(Duration::from_secs(10)) - .pool_max_idle_per_host(1) - .build() - .map_err(|error| format!("github client failed: {error}"))?; + let client = github_client()?; let url = format!( "https://api.github.com/repos/{owner}/{repo}/commits/{sha}/check-runs?per_page=100" @@ -235,11 +244,7 @@ pub async fn fetch_github_pr_comment_state( return Err("invalid GitHub repository reference".to_string()); } - let client = reqwest::Client::builder() - .pool_idle_timeout(Duration::from_secs(10)) - .pool_max_idle_per_host(1) - .build() - .map_err(|error| format!("github client failed: {error}"))?; + let client = github_client()?; let base = format!("https://api.github.com/repos/{owner}/{repo}"); let build = |url: String| { @@ -357,11 +362,7 @@ pub async fn find_github_pr_for_branch( return Ok(None); } - let client = reqwest::Client::builder() - .pool_idle_timeout(Duration::from_secs(10)) - .pool_max_idle_per_host(1) - .build() - .map_err(|error| format!("github client failed: {error}"))?; + let client = github_client()?; let build = |url: String| { let mut request = client .get(url) diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts index 0adc0992ee..0035c5e9f7 100644 --- a/desktop/src/features/chats/lib/chatWorkAutomation.ts +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -99,7 +99,20 @@ export function useChatWorkAutomation(chatId: string): ChatWorkAutomation { ); React.useEffect(() => { - const refresh = () => setState(readChatWorkAutomation(chatId)); + // Content-compare: a fresh object per storage event would re-run every + // consumer effect (and re-render every panel) even when nothing changed. + const refresh = () => + setState((current) => { + const next = readChatWorkAutomation(chatId); + return current.autoFixCi === next.autoFixCi && + current.addressComments === next.addressComments && + current.lastCiNudgeSha === next.lastCiNudgeSha && + current.lastCommentNudgeCount === next.lastCommentNudgeCount && + current.lastCiNudgeAt === next.lastCiNudgeAt && + current.lastCommentNudgeAt === next.lastCommentNudgeAt + ? current + : next; + }); refresh(); window.addEventListener(STORAGE_EVENT, refresh); window.addEventListener("storage", refresh); diff --git a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx index fd26c9b433..c5311653ea 100644 --- a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx +++ b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx @@ -87,47 +87,52 @@ function isSetupLifecycleItem(item: TranscriptItem) { ); } -export function ChatActivityTranscript({ - activeTurnIds, - agent, - blocks, - identityPubkey, - profiles, - showAgentIdentity = true, -}: { - /** Turn ids currently live in this channel — drives per-turn rendering. */ - activeTurnIds?: ReadonlySet; - agent: ManagedAgent | null; - blocks: ChatActivityRenderBlock[]; - identityPubkey?: string; - profiles?: UserProfileLookup; - /** Hidden in solo chats so agent replies read as part of the stream. */ - showAgentIdentity?: boolean; -}) { - if (blocks.length === 0) { - return null; - } +// Memoized: rendered once per anchored message; placement recomputes on +// every transcript event, but rows whose props didn't change must not +// re-render their Markdown trees. +export const ChatActivityTranscript = React.memo( + function ChatActivityTranscript({ + activeTurnIds, + agent, + blocks, + identityPubkey, + profiles, + showAgentIdentity = true, + }: { + /** Turn ids currently live in this channel — drives per-turn rendering. */ + activeTurnIds?: ReadonlySet; + agent: ManagedAgent | null; + blocks: ChatActivityRenderBlock[]; + identityPubkey?: string; + profiles?: UserProfileLookup; + /** Hidden in solo chats so agent replies read as part of the stream. */ + showAgentIdentity?: boolean; + }) { + if (blocks.length === 0) { + return null; + } - return ( - <> - {blocks.map((renderBlock) => ( - - ))} - - ); -} + return ( + <> + {blocks.map((renderBlock) => ( + + ))} + + ); + }, +); function ChatActivityBlockView({ agent, diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index 3bae8289b1..59a2962e40 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -35,7 +35,10 @@ function profileName( ); } -export function ChatMessageRow({ +// 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, @@ -138,7 +141,7 @@ export function ChatMessageRow({ ); -} +}); // Following the stream is owned entirely by the MessageScroller's built-in // autoScroll (content mutation + resize observers). This anchor only handles diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index 9544e782d1..5aceb466a6 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -125,15 +125,22 @@ export function ChatDetail({ // 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(); - const activeAgentPubkeys = React.useMemo(() => { + // 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(); + 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 From ab483ea93c802c6b74019f8c8ad999edff71de16 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 07:39:46 +0100 Subject: [PATCH 55/68] Always surface a stopped default agent; acknowledge Run now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activation card: a stopped default agent now always shows the card — "is it running or just silent?" must never be ambiguous, especially since automation prompts tag the default agent and go nowhere while it's stopped. The delayed path still covers running-but-unresponsive. Run now: the automation prompt is invisible in the timeline, so firing one now toasts "Asked {agent} to fix the CI failures" / "…address the review comments". (The prompts already mention the default agent — the send path tags it on every message — so multi-agent chats route the work to the default agent by construction.) Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/ui/ChatDetail.tsx | 9 ++++++++- desktop/src/features/chats/ui/ChatWorkPanel.tsx | 11 +++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index 5aceb466a6..73ec971ab8 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -539,8 +539,14 @@ export function ChatDetail({ hasObserver, latestOwnMessageNeedsAgent, ]); + // A stopped default agent always shows the card — "is it running, is it + // silent?" must never be ambiguous. The delayed path still covers a + // running-but-unresponsive agent. + const defaultAgentInactive = + defaultAgent != null && !isManagedAgentActive(defaultAgent); const shouldShowAgentActivationCard = - latestOwnMessageNeedsAgent && (!hasObserver || showDelayedActivationCard); + (defaultAgentInactive && latestVisibleMessage != null) || + (latestOwnMessageNeedsAgent && (!hasObserver || showDelayedActivationCard)); const forceScrollSignature = latestVisibleMessageIsOwn ? latestVisibleMessage.id : null; @@ -728,6 +734,7 @@ export function ChatDetail({ { if (!onAutomationPrompt || !effectiveHref) { return; @@ -155,7 +161,8 @@ export function ChatWorkPanel({ 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.`, ); - }, [chatId, effectiveHref, onAutomationPrompt, openThreads]); + 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 From 93b0c2a879dcadab96502fab8defc2506237b55b Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 08:04:37 +0100 Subject: [PATCH 56/68] Post-merge reconciliation with latest main Adapts the branch to main's API moves: Markdown dropped the compact prop; ToolDetailBlocks requires fileReadContent; formatOwnerLabel gains main's "you" short-circuit; the mention send flow's invite helper carries the captured channel id; the composer props file ports onCaptureSendContext. Lint deltas from main's stricter config are fixed properly (own-bubble chip rules reordered below the standalone rules, pass-through switch cases folded into default, pulse suppressions re-anchored). Size ceilings are restored by extracting the NIP-AB pairing API from tauri.ts and the find-target splice state from ChannelScreen into useFindTargetEvents. Co-Authored-By: Claude Fable 5 --- .../features/agents/activeAgentTurnsStore.ts | 4 +- .../features/channels/ui/ChannelScreen.tsx | 25 +++------ .../chats/ui/ChatActivityTranscript.tsx | 18 +++--- .../chats/ui/ChatConversationRows.tsx | 8 +-- .../messages/lib/mentionCandidates.ts | 2 +- desktop/src/features/search/useChannelFind.ts | 28 +++++++++- .../settings/ui/MobilePairingCard.tsx | 2 +- desktop/src/shared/api/tauri.ts | 14 ----- desktop/src/shared/api/tauriPairing.ts | 15 +++++ .../src/shared/styles/globals/markdown.css | 56 +++++++++---------- 10 files changed, 90 insertions(+), 82 deletions(-) create mode 100644 desktop/src/shared/api/tauriPairing.ts diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 13252de50e..cc63ecc56f 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -354,9 +354,7 @@ function processEvent(agentPubkey: string, event: ObserverEvent) { ); notifyListeners(); return; - case "acp_read": - case "acp_write": - case "turn_liveness": + // 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` diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 54b128c578..240e1cb850 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import { useAppShell } from "@/app/AppShellContext"; -import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader"; import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers"; @@ -55,8 +54,11 @@ import { useChannelTyping } from "@/features/messages/useChannelTyping"; import type { TimelineMessage } from "@/features/messages/types"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity"; -import type { RelayEvent, RespondToMode, SearchHit } from "@/shared/api/types"; -import { useChannelFind } from "@/features/search/useChannelFind"; +import type { RespondToMode } from "@/shared/api/types"; +import { + useChannelFind, + useFindTargetEvents, +} from "@/features/search/useChannelFind"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; @@ -240,13 +242,8 @@ export function ChannelScreen({ const deleteMessageMutation = useDeleteMessageMutation(activeChannel); const editMessageMutation = useEditMessageMutation(activeChannel); const joinChannelMutation = useJoinChannelMutation(activeChannelId); - const [findTargetEvents, setFindTargetEvents] = React.useState( - [], - ); - // biome-ignore lint/correctness/useExhaustiveDependencies: clear spliced find results exactly when the active channel changes. - React.useEffect(() => { - setFindTargetEvents([]); - }, [activeChannelId]); + const { findTargetEvents, handleFindSearchHit } = + useFindTargetEvents(activeChannelId); const resolvedMessages = React.useMemo(() => { const currentMessages = messagesQuery.data ?? []; const extraEvents = [...targetMessageEvents, ...findTargetEvents]; @@ -428,14 +425,6 @@ export function ChannelScreen({ : new Map(), [windowQuery.data], ); - const handleFindSearchHit = React.useCallback((hit: SearchHit) => { - const event = cacheSearchHitEvent(hit); - setFindTargetEvents((currentEvents) => - currentEvents.some((currentEvent) => currentEvent.id === event.id) - ? currentEvents - : [...currentEvents, event], - ); - }, []); const channelFind = useChannelFind({ channelId: activeChannelId, messages: timelineMessages, diff --git a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx index c5311653ea..483fb3a1f3 100644 --- a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx +++ b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx @@ -372,7 +372,7 @@ function ChatActivityItemView({ if (item.type === "thought") { return ( } + details={} entrance={hasRecentEntrance(item.timestamp)} icon={} label={item.title || "Thinking"} @@ -392,10 +392,7 @@ function ChatActivityItemView({ + ) } entrance={hasRecentEntrance(item.timestamp)} @@ -426,7 +423,7 @@ function ChatActivityItemView({ return ( : null} + details={item.text ? : null} entrance={hasRecentEntrance(item.timestamp)} icon={lifecycleIcon(item)} label={item.title} @@ -494,7 +491,6 @@ function ChatTranscriptMessageRow({ @@ -664,6 +660,7 @@ function ToolMarker({ args={item.args} description={buzzTool?.label} fileEditDiff={compactSummary.fileEditDiff} + fileReadContent={compactSummary.fileReadContent} hasArgs={hasArgs} hasResult={hasResult} imagePreview={ @@ -734,6 +731,7 @@ function activityItemDetails(item: TranscriptItem) { args={item.args} description={buzzTool?.label} fileEditDiff={compactSummary.fileEditDiff} + fileReadContent={compactSummary.fileReadContent} hasArgs={hasArgs} hasResult={hasResult} imagePreview={ @@ -754,10 +752,10 @@ function activityItemDetails(item: TranscriptItem) { return ; } if (item.type === "plan" || item.type === "thought") { - return ; + return ; } if (item.type === "lifecycle") { - return item.text ? : null; + return item.text ? : null; } return null; } @@ -781,7 +779,7 @@ function PromptSections({ sections }: { sections: PromptSection[] }) {
- +
))} diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index 59a2962e40..a0ad5e8388 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -131,7 +131,6 @@ export const ChatMessageRow = React.memo(function ChatMessageRow({ isOwn && "[&_*]:text-primary-foreground [&_a]:text-primary-foreground [&_code]:bg-primary-foreground/15 [&_code]:text-primary-foreground", )} - compact content={content || " "} mentionNames={mentionNames} mentionPubkeysByName={mentionPubkeysByName} @@ -187,10 +186,7 @@ export function ChatContextRow({ event }: { event: RelayEvent }) { Project setup
- +
@@ -208,7 +204,7 @@ export function ChatContextRow({ event }: { event: RelayEvent }) { Source context - + diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index a8197cdeab..194a028825 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -76,6 +76,6 @@ export function formatOwnerLabel( return ( owner?.displayName?.trim() || owner?.nip05Handle?.trim() || - `${ownerPubkey.slice(0, 8)}...` + `${ownerPubkey.slice(0, 8)}…` ); } 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/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index cba2f51f90..f181d04fc8 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1333,20 +1333,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/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/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 2f7ef36a0d..10b75a4a99 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -72,34 +72,6 @@ color: var(--buzz-link-preview-icon-color); } -/* 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; -} - .message-markdown .mention-chip, .message-markdown .inline-code-chip, .message-markdown :not(pre) > code { @@ -136,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 { From 09b61fbc85b9656ae8975eac588b7654ab9810af Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 08:17:19 +0100 Subject: [PATCH 57/68] Recognize compatibility chats on relays without channel_type: chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staging relay predates channel_type "chat", so chat creation falls back to a plain private channel and every harness behavior keyed on channel_type == "chat" silently degrades: the activation backlog replay never runs (Activate starts the agent but the pending message is never delivered — only a fresh @mention wakes it), reactions aren't suppressed, and titles aren't emitted. Confirmed in agent logs: no subscription ever took the "(chat backlog since …)" path. Discovery now reclassifies member channels that carry chat metadata (kind 30623, or the legacy 30078 buzz:chat events) as chats, at startup and on membership-notification refresh, so chat behaviors work against older relays. Co-Authored-By: Claude Fable 5 --- crates/buzz-acp/src/lib.rs | 78 +++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index ad1874b402..e019865448 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -135,6 +135,75 @@ async fn chat_backlog_replay_since( } } +/// 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: @@ -1382,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(); @@ -1830,6 +1901,11 @@ async fn tokio_main() -> Result<()> { 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!( From 73340ac6ce7030cfcfd036ab9ac004514d58db7b Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 08:22:38 +0100 Subject: [PATCH 58/68] Make Run now trustworthy and the sidebar shimmer unmissable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run now: the toast only proved the click happened — the send's failure was swallowed, the prompt was fully invisible, and a stopped default agent silently ate it. Automation prompts now surface send errors, auto-start a stopped default agent (the backlog replay delivers the prompt once it connects), and leave a collapsed marker row at the spot the instruction fired — "Asked Fizz to fix the CI failures" — expandable to the full text, so the magic has an anchor. Shimmer: glyph recoloring alone is too subtle at sidebar sizes. The accent shimmer adds a translucent primary highlight wash sweeping behind the text on the same keyframes — a highlighter pass the text stays readable through. Co-Authored-By: Claude Fable 5 --- .../features/chats/lib/chatWorkAutomation.ts | 19 +++++++++ .../chats/ui/ChatConversationRows.tsx | 39 ++++++++++++++++++- desktop/src/features/chats/ui/ChatDetail.tsx | 35 ++++++++++++++--- .../src/features/chats/ui/ChatWorkPanel.tsx | 4 +- .../src/shared/styles/globals/animations.css | 30 ++++++++++++++ desktop/tests/e2e/chats-first-message.spec.ts | 8 +++- 6 files changed, 127 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts index 0035c5e9f7..25e3127a08 100644 --- a/desktop/src/features/chats/lib/chatWorkAutomation.ts +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -18,6 +18,25 @@ export const CHAT_AUTOMATION_TAG: [string, string] = [ "work-panel", ]; +/** Tag for one automation prompt, carrying its kind for the marker row. */ +export function chatAutomationTag(kind: "ci" | "comments"): string[] { + return [...CHAT_AUTOMATION_TAG, kind]; +} + +/** Marker-row label for an automation message's tag. */ +export function chatAutomationLabel( + tag: readonly string[] | undefined, + agentName: string, +) { + if (tag?.[2] === "ci") { + return `Asked ${agentName} to fix the CI failures`; + } + if (tag?.[2] === "comments") { + return `Asked ${agentName} to address the review comments`; + } + return `Sent ${agentName} automation instructions`; +} + export type ChatWorkAutomation = { autoFixCi: boolean; addressComments: boolean; diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index a0ad5e8388..96d6af6630 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -1,6 +1,7 @@ import * as React from "react"; -import { Bot, FolderGit2, MessageCircle, Power } from "lucide-react"; +import { Bot, FolderGit2, 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"; @@ -168,6 +169,42 @@ export function ChatScrollAnchor({ 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] === "automation"); + return ( + + +
+ + + + {chatAutomationLabel(tag, agentName)} + + +
+ {event.content} +
+
+
+
+ ); +} + export function ChatContextRow({ event }: { event: RelayEvent }) { const isProjectSetup = event.content.startsWith("Project setup"); const projectSetupContent = event.content diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index 73ec971ab8..e45bdab923 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -27,7 +27,10 @@ import { NO_PROJECT_SELECTION_ID, } from "@/features/chats/lib/chatSetup"; import { ChatActivityTranscript } from "@/features/chats/ui/ChatActivityTranscript"; -import { CHAT_AUTOMATION_TAG } from "@/features/chats/lib/chatWorkAutomation"; +import { + CHAT_AUTOMATION_TAG, + chatAutomationTag, +} from "@/features/chats/lib/chatWorkAutomation"; import { deriveBranchFromAgentMessages, deriveChatWorkBranch, @@ -38,6 +41,7 @@ import { isHumanFacingAssistantText } from "@/features/chats/ui/chatActivityText import { entranceClassForCreatedAt } from "@/features/chats/ui/messageEntrance"; import { AgentActivationCard, + ChatAutomationRow, ChatContextRow, ChatMessageRow, ChatScrollAnchor, @@ -501,6 +505,24 @@ export function ChatDetail({ 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], + ); + React.useEffect(() => { if (!isActivationPending) { return; @@ -637,7 +659,12 @@ export function ChatDetail({ )} messageId={message.id} > - {isAutomationMessage ? null : isContextMessage ? ( + {isAutomationMessage ? ( + + ) : isContextMessage ? ( ) : ( - void onSend(content, [], [CHAT_AUTOMATION_TAG]) - } + onAutomationPrompt={handleAutomationPrompt} open={showWorkPanel} prHref={workPanelHref} projectPath={metadata?.projectPath ?? selectedProject?.path ?? null} diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 553b1b38d7..574f6f7bc7 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -68,7 +68,7 @@ export function ChatWorkPanel({ chatId: string; /** Whether an agent turn is currently running in this chat. */ isTurnActive?: boolean; - onAutomationPrompt?: (content: string) => void; + onAutomationPrompt?: (content: string, kind: "ci" | "comments") => void; open?: boolean; prHref?: string | null; /** Project directory — enables PR discovery by branch via the git remote. */ @@ -146,6 +146,7 @@ export function ChatWorkPanel({ }); 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`); @@ -160,6 +161,7 @@ export function ChatWorkPanel({ }); 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]); diff --git a/desktop/src/shared/styles/globals/animations.css b/desktop/src/shared/styles/globals/animations.css index 6c7daa537b..b1efedbb2d 100644 --- a/desktop/src/shared/styles/globals/animations.css +++ b/desktop/src/shared/styles/globals/animations.css @@ -246,6 +246,36 @@ --buzz-shimmer-highlight: color-mix(in oklch, hsl(var(--primary)) 60%, black); } +/* Glyph recoloring alone is too subtle at sidebar sizes — sweep a + translucent highlight WASH behind the text as well (same keyframes, so + band and wash move together). The wash is a soft primary tint the text + stays readable through, like a highlighter pass. */ +.buzz-shimmer-accent::after { + animation: buzz-shimmer var(--buzz-shimmer-duration) linear infinite; + background-image: linear-gradient( + 90deg, + transparent 0%, + transparent 40%, + hsl(var(--primary) / 0.28) 50%, + transparent 60%, + transparent 100% + ); + background-repeat: no-repeat; + background-size: var(--buzz-shimmer-band) 100%; + border-radius: 0.25rem; + content: ""; + inset: -0.125rem -0.25rem; + pointer-events: none; + position: absolute; +} + +@media (prefers-reduced-motion: reduce) { + .buzz-shimmer-accent::after { + animation: none; + content: none; + } +} + @media (prefers-reduced-motion: reduce) { .buzz-shimmer, .shimmer { diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index a45de1dfbe..c44b4b2f4d 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -214,9 +214,15 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { ), ) .toBe(true); + // The instruction renders as a collapsed marker row, not a bubble. + const automationRow = page.getByTestId("chat-automation-row"); + await expect(automationRow).toBeVisible({ timeout: 10_000 }); + await expect(automationRow).toContainText( + "Asked Fizz to address the review comments", + ); await expect( page.getByLabel("Chat messages").getByText("unanswered review comments"), - ).toHaveCount(0); + ).not.toBeVisible(); // Manual overrides: open comments expose "Run now"; green CI does not. await expect(page.getByTestId("automation-run-comments-now")).toBeVisible(); From 2f4867619ff9c75a659b62ccb521a07bdba71f46 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 08:30:10 +0100 Subject: [PATCH 59/68] Pin each chat's PR so panels stop converging on one PR Two live effects made different chats' work panels show the same PR: the windowed message fetch ages the posted PR link out of the loaded history (losing the strongest per-chat signal), and agents reuse a worktree across chats in one project, so branch discovery resolves several chats to the same pull request. Each chat now pins the PR it resolves (localStorage): a link posted in the chat always wins and re-pins, the pin covers revisits after the link leaves the window, and branch discovery only runs for chats with no posted link and no pin. Co-Authored-By: Claude Fable 5 --- .../features/chats/lib/chatWorkAutomation.ts | 30 +++++++++++++++++++ .../src/features/chats/ui/ChatWorkPanel.tsx | 15 ++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts index 25e3127a08..ee02f9b9f5 100644 --- a/desktop/src/features/chats/lib/chatWorkAutomation.ts +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -143,3 +143,33 @@ export function useChatWorkAutomation(chatId: string): ChatWorkAutomation { return state; } + +const PR_STORAGE_PREFIX = "buzz:chat-work-pr:v1"; + +/** + * The PR pinned to a chat. Posted links age out of the windowed message + * fetch and branch discovery can resolve several chats sharing a reused + * worktree to the same PR — the pin keeps each chat on the PR it actually + * resolved first, with posted links always overriding. + */ +export function readChatPinnedPr(chatId: string): string | null { + if (typeof window === "undefined") { + return null; + } + try { + return window.localStorage.getItem(`${PR_STORAGE_PREFIX}:${chatId}`); + } catch { + return null; + } +} + +export function writeChatPinnedPr(chatId: string, href: string) { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.setItem(`${PR_STORAGE_PREFIX}:${chatId}`, href); + } catch { + // Best-effort. + } +} diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 574f6f7bc7..4d14b03e50 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -11,8 +11,10 @@ import { } from "lucide-react"; import { + readChatPinnedPr, updateChatWorkAutomation, useChatWorkAutomation, + writeChatPinnedPr, } from "@/features/chats/lib/chatWorkAutomation"; import { type GithubCheckSummary, @@ -83,11 +85,20 @@ export function ChatWorkPanel({ // hidden panel stops polling entirely. const monitorActive = open || automation.autoFixCi || automation.addressComments; + // Pin resolution order: a link posted in THIS chat wins, then the chat's + // previously pinned PR, then branch discovery — discovery alone is + // ambiguous when agents reuse a worktree across chats in one project. + const pinnedHref = React.useMemo(() => readChatPinnedPr(chatId), [chatId]); const discoveredPrQuery = useGithubPrForBranchQuery( - monitorActive && !prHref ? projectPath : null, + monitorActive && !prHref && !pinnedHref ? projectPath : null, branch, ); - const effectiveHref = prHref ?? discoveredPrQuery.data ?? null; + const effectiveHref = prHref ?? pinnedHref ?? discoveredPrQuery.data ?? null; + React.useEffect(() => { + if (effectiveHref && effectiveHref !== readChatPinnedPr(chatId)) { + writeChatPinnedPr(chatId, effectiveHref); + } + }, [chatId, effectiveHref]); const preview = effectiveHref ? parseSupportedLinkPreview(effectiveHref) : null; From 1f923a89ef61ecf0e0a45ad4d97c430fc948ff63 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 08:35:16 +0100 Subject: [PATCH 60/68] Clip the sidebar shimmer wash to the chat name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glyph overlay clips to text by construction, but the new highlight wash is a box on the span — and the span is flex-1, so short names swept a full-row highlight. The shimmer now lives on an inner inline-block sized to the text (self-truncating, so the overlay ellipsis stays aligned); the outer span keeps the row layout. Co-Authored-By: Claude Fable 5 --- .../src/features/chats/ui/ChatListItem.tsx | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatListItem.tsx b/desktop/src/features/chats/ui/ChatListItem.tsx index 7b0bcd3ba8..71d8b5df6a 100644 --- a/desktop/src/features/chats/ui/ChatListItem.tsx +++ b/desktop/src/features/chats/ui/ChatListItem.tsx @@ -82,17 +82,20 @@ export function ChatListItem({ onClick={() => onSelectChat(chat.id)} type="button" > - - {name} + + {/* Shimmer lives on an inner inline-block sized to the TEXT (the + outer span is flex-1 = full row, and the highlight wash would + otherwise sweep the whole row). The inner truncates itself so + the overlay's ellipsis stays aligned with the visible text. */} + + {name} + {isPinned ? ( Date: Sun, 5 Jul 2026 08:45:07 +0100 Subject: [PATCH 61/68] Never hide a real agent reply behind narration heuristics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns visibly worked but never answered: two filters could swallow a genuinely sent reply. The chat timeline ran persisted agent messages through the transcript's internal-narration patterns, so replies like "Done! I've sent the summary…" were dropped outright; and the consecutive-run collapse preferred the last "human-facing" message, hiding a narration-styled FINAL reply behind an earlier one. Persisted messages now render on content alone (narration shaping stays in the activity transcript, dedup still applies), and the run collapse hides only interim narration — every substantive message stays, and the run's final message always stays regardless of phrasing. Regression spec: a narration-styled final reply must render alongside the earlier PR announcement. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/lib/chatActivity.ts | 17 ++++++++++------- desktop/src/features/chats/ui/ChatDetail.tsx | 16 ++++++++-------- desktop/tests/e2e/chats-first-message.spec.ts | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/desktop/src/features/chats/lib/chatActivity.ts b/desktop/src/features/chats/lib/chatActivity.ts index e8ffb16369..44c1eeaf5d 100644 --- a/desktop/src/features/chats/lib/chatActivity.ts +++ b/desktop/src/features/chats/lib/chatActivity.ts @@ -159,14 +159,17 @@ function addIntermediateAgentTurnMessageIds({ return; } - const keepMessage = - [...agentRun] - .reverse() - .find((message) => isHumanFacingAssistantText(message.content)) ?? - agentRun[agentRun.length - 1]; - + // Hide only interim narration: substantive messages (PR announcements, + // answers) all stay, and the run's FINAL message always stays even when + // narration-styled — a real reply must never lose to a phrasing + // heuristic, which is exactly how turns ended up visibly working but + // never answering. + const lastId = agentRun[agentRun.length - 1].id; for (const message of agentRun) { - if (message.id !== keepMessage.id) { + if ( + message.id !== lastId && + !isHumanFacingAssistantText(message.content) + ) { hiddenIds.add(message.id); } } diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index e45bdab923..746bb415fe 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -302,22 +302,22 @@ export function ChatDetail({ ) { return false; } - const isAgent = - defaultAgent?.pubkey != null && - normalizePubkey(message.pubkey) === - normalizePubkey(defaultAgent.pubkey); + // 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") || - (isAgent - ? isHumanFacingAssistantText(message.content) - : message.content.trim().length > 0)) && + message.content.trim().length > 0) && !shouldHidePersistedAgentMessage({ event: message, hiddenAgentMessageIds: chatActivity.hiddenAgentMessageIds, }) ); }), - [chatActivity.hiddenAgentMessageIds, defaultAgent?.pubkey, messages], + [chatActivity.hiddenAgentMessageIds, messages], ); const hasTranscriptActivity = chatActivity.totalBlockCount > 0; diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index c44b4b2f4d..133a39c788 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -118,6 +118,15 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { createdAt: base + 2, pubkey: pubkey ?? undefined, }); + // A narration-styled FINAL reply must still render: persisted + // messages are never filtered by the transcript's narration + // heuristics (this exact phrasing used to be silently dropped). + win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "Hello Fizz, first message", + content: "Done! I've sent the summary with details to the channel.", + createdAt: base + 3, + pubkey: pubkey ?? undefined, + }); // A human message with a mention tag: @bob must render as a chip // (alice's pubkey is a mock profile fixture; bob's resolves via the // message's p tag). @@ -135,6 +144,12 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { { pubkey: fizzPubkey }, ); + await expect( + page + .getByLabel("Chat messages") + .getByText("Done! I've sent the summary with details to the channel."), + ).toBeVisible({ timeout: 10_000 }); + // Mentions in chat messages render as chips, same as channels. await expect( page.getByLabel("Chat messages").locator("[data-mention]", { From 4fc78847b1ab39fef77cf18593ac9ff26fc81db9 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 08:58:13 +0100 Subject: [PATCH 62/68] Route automation marker tags through the client tag channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every automation send was failing client-side: the marker tag rode the outgoing media channel, whose builder rejects any non-imeta prefix — so Run now toasted an ask (now an error, after the surfacing fix) but no prompt ever reached the relay, the agent never started, and the activation card sat on "Starting…" forever. The e2e mock accepted the invalid tag, which is how the route stayed green while broken live. The marker now rides the existing whitelisted ["client", ...] channel: send_channel_message accepts client_tags and threads them to build_message_with_client_tags; splitOutgoingTags gets a clientTags bucket (client tags force the REST path so the tag-validating builder runs); and the mock now enforces the imeta-only media gate and echoes client tags, closing the test blind spot. The event→model converters move to message_converters.rs to hold the size ceiling. Co-Authored-By: Claude Fable 5 --- .../src/commands/message_converters.rs | 96 ++++++++++++++++ desktop/src-tauri/src/commands/messages.rs | 104 ++---------------- desktop/src-tauri/src/commands/mod.rs | 1 + .../features/chats/lib/chatWorkAutomation.ts | 7 +- .../chats/ui/ChatConversationRows.tsx | 4 +- desktop/src/features/messages/hooks.ts | 15 ++- .../messages/lib/imetaMediaMarkdown.test.mjs | 11 +- .../messages/lib/imetaMediaMarkdown.ts | 9 +- desktop/src/shared/api/tauri.ts | 2 + desktop/src/testing/e2eBridge.ts | 18 ++- desktop/tests/e2e/chats-first-message.spec.ts | 6 +- 11 files changed, 162 insertions(+), 111 deletions(-) create mode 100644 desktop/src-tauri/src/commands/message_converters.rs 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 9b6c8a9de5..05fd83959b 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -22,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; diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts index ee02f9b9f5..4470275d66 100644 --- a/desktop/src/features/chats/lib/chatWorkAutomation.ts +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -13,10 +13,9 @@ const STORAGE_EVENT = "buzz:chat-work-automation-changed"; * the chat timeline renders only its activity — not the message bubble — so * armed automation feels ambient instead of ventriloquized. */ -export const CHAT_AUTOMATION_TAG: [string, string] = [ - "automation", - "work-panel", -]; +// Rides the whitelisted ["client", ...] marker-tag channel: the imeta-only +// media path rejects any other prefix and fails the whole send. +export const CHAT_AUTOMATION_TAG: [string, string] = ["client", "automation"]; /** Tag for one automation prompt, carrying its kind for the marker row. */ export function chatAutomationTag(kind: "ci" | "comments"): string[] { diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index 96d6af6630..858890671b 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -182,7 +182,9 @@ export function ChatAutomationRow({ agentName: string; event: RelayEvent; }) { - const tag = event.tags.find((candidate) => candidate[0] === "automation"); + const tag = event.tags.find( + (candidate) => candidate[0] === "client" && candidate[1] === "automation", + ); return ( 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/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index f181d04fc8..016f1647de 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -837,6 +837,7 @@ export async function sendChannelMessage( kind?: number, emojiTags?: string[][], mentionTags?: string[][], + clientTags?: string[][], ): Promise { const response = await invokeTauri( "send_channel_message", @@ -849,6 +850,7 @@ export async function sendChannelMessage( mentionTags: mentionTags ?? null, mentionPubkeys: mentionPubkeys ?? null, kind: kind ?? null, + clientTags: clientTags ?? null, }, ); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1b4f02d4b7..c968d9778f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -7172,6 +7172,7 @@ async function handleSendChannelMessage( mentionPubkeys?: string[]; mediaTags?: string[][] | null; emojiTags?: string[][] | null; + clientTags?: string[][] | null; }, config: E2eConfig | undefined, ): Promise { @@ -7187,12 +7188,25 @@ async function handleSendChannelMessage( // event; mirror that here so attachment renderers (FileCard, images, video) // have the imeta tags they key on. `null`/empty → no extra tags. const mediaTags = args.mediaTags ?? []; + // Mirror the Rust builder's imeta-only gate: a non-imeta tag on the media + // channel fails the real send, and the mock accepting it is exactly how a + // broken-live tag route stayed green in e2e. + for (const tag of mediaTags) { + if (tag[0] !== "imeta") { + throw new Error(`media tags must use 'imeta' prefix (got ${tag[0]})`); + } + } // NIP-30 custom-emoji tags ride their own validated arg server-side; the // relay echoes them back on the stored event too, so mirror that here so the // emoji renderer keeps resolving `:shortcode:` after the round-trip. const emojiTags = args.emojiTags ?? []; - // Both kinds end up on the stored event's tag set, just like the real relay. - const extraTags = [...mediaTags, ...emojiTags]; + // Whitelisted ["client", ...] marker tags (automation prompts) — echoed + // back on the stored event like the real builder. + const clientTags = (args.clientTags ?? []).filter( + (tag) => tag[0] === "client", + ); + // All kinds end up on the stored event's tag set, just like the real relay. + const extraTags = [...mediaTags, ...emojiTags, ...clientTags]; const identity = getIdentity(config); if (!identity) { const createdAt = Math.floor(Date.now() / 1000); diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 133a39c788..116d84bf2b 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -214,7 +214,7 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { window as Window & { __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string; - payload?: { content?: string; mediaTags?: string[][] }; + payload?: { content?: string; clientTags?: string[][] }; }>; } ).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [] @@ -222,8 +222,8 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { (entry) => entry.command === "send_channel_message" && entry.payload?.content?.includes("unanswered review comments") && - entry.payload?.mediaTags?.some( - (tag) => tag[0] === "automation" && tag[1] === "work-panel", + entry.payload?.clientTags?.some( + (tag) => tag[0] === "client" && tag[1] === "automation", ), ), ), From c9236b39102f8a0fbe874ecb4bdd288de576ec96 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 09:01:34 +0100 Subject: [PATCH 63/68] Let a chat unlink a mispinned PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worktree reuse can make branch discovery resolve a chat to another chat's PR, and the pin then locks the mistake in. A discovered/pinned card (never a posted link) now shows "Not this chat's PR? Unlink" — unlinking stores an explicit no-PR sentinel that keeps discovery off for that chat while a posted link still overrides everything. Co-Authored-By: Claude Fable 5 --- .../features/chats/lib/chatWorkAutomation.ts | 6 ++++ .../src/features/chats/ui/ChatWorkPanel.tsx | 32 +++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts index 4470275d66..11b00fad11 100644 --- a/desktop/src/features/chats/lib/chatWorkAutomation.ts +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -162,6 +162,12 @@ export function readChatPinnedPr(chatId: string): string | null { } } +/** + * Explicit "no PR" pin: suppresses branch discovery for the chat (the user + * said the discovered PR was wrong) while posted links still override. + */ +export const CHAT_PR_UNPINNED = ""; + export function writeChatPinnedPr(chatId: string, href: string) { if (typeof window === "undefined") { return; diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 4d14b03e50..4904d3555a 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import { + CHAT_PR_UNPINNED, readChatPinnedPr, updateChatWorkAutomation, useChatWorkAutomation, @@ -88,17 +89,32 @@ export function ChatWorkPanel({ // Pin resolution order: a link posted in THIS chat wins, then the chat's // previously pinned PR, then branch discovery — discovery alone is // ambiguous when agents reuse a worktree across chats in one project. - const pinnedHref = React.useMemo(() => readChatPinnedPr(chatId), [chatId]); + const [pinnedHref, setPinnedHref] = React.useState(() => + readChatPinnedPr(chatId), + ); + React.useEffect(() => { + setPinnedHref(readChatPinnedPr(chatId)); + }, [chatId]); + // The empty-string sentinel means "user unlinked — no PR for this chat": + // discovery stays off, posted links still win. + const isUnpinned = pinnedHref === CHAT_PR_UNPINNED; const discoveredPrQuery = useGithubPrForBranchQuery( - monitorActive && !prHref && !pinnedHref ? projectPath : null, + monitorActive && !prHref && pinnedHref === null ? projectPath : null, branch, ); - const effectiveHref = prHref ?? pinnedHref ?? discoveredPrQuery.data ?? null; + const effectiveHref = + prHref ?? + (isUnpinned ? null : (pinnedHref ?? discoveredPrQuery.data ?? null)); React.useEffect(() => { if (effectiveHref && effectiveHref !== readChatPinnedPr(chatId)) { writeChatPinnedPr(chatId, effectiveHref); + setPinnedHref(effectiveHref); } }, [chatId, effectiveHref]); + const handleUnlinkPr = React.useCallback(() => { + writeChatPinnedPr(chatId, CHAT_PR_UNPINNED); + setPinnedHref(CHAT_PR_UNPINNED); + }, [chatId]); const preview = effectiveHref ? parseSupportedLinkPreview(effectiveHref) : null; @@ -259,6 +275,16 @@ export function ChatWorkPanel({ key={preview.href} > + {!prHref ? ( + + ) : null}
Date: Sun, 5 Jul 2026 09:05:22 +0100 Subject: [PATCH 64/68] Persist the work drawer state; shimmer washes toward background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drawer: the open/closed preference reset on every chat switch, so the panel re-animated open each time a PR chat was selected. The toggle is now one persisted preference — switching chats renders the drawer in its remembered state with no animation; auto-open-on-PR remains only until the user toggles once. Shimmer: the band color moves close to the background (a whisper of primary keeps the hue) so glyphs visibly wash out as it passes — the strongest contrast against full-color text — and the highlight wash drops to a soft glow instead of a gray slab. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/ui/ChatsScreen.tsx | 31 +++++++++++++------ .../src/shared/styles/globals/animations.css | 11 +++---- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatsScreen.tsx b/desktop/src/features/chats/ui/ChatsScreen.tsx index b2af641d5f..5fb4be5441 100644 --- a/desktop/src/features/chats/ui/ChatsScreen.tsx +++ b/desktop/src/features/chats/ui/ChatsScreen.tsx @@ -55,6 +55,8 @@ 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; @@ -335,16 +337,27 @@ export function ChatsScreen({ } return null; }, [messages]); - // null = auto: open once the agent has produced a PR, closed otherwise. - // A click stores an explicit preference for the current chat. + // The drawer's open state is a single persisted preference — switching + // chats must NOT reset it (the panel used to re-animate open on every + // switch). null = never toggled: auto-open when the chat has a PR. const [workPanelPreference, setWorkPanelPreference] = React.useState< boolean | null - >(null); - const workPanelChatRef = React.useRef(selectedChatId); - if (workPanelChatRef.current !== selectedChatId) { - workPanelChatRef.current = selectedChatId; - setWorkPanelPreference(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 ?? agentPullRequestHref !== null; const startManagedAgentMutation = useStartManagedAgentMutation(); const [isEnsuringDefaultAgent, setIsEnsuringDefaultAgent] = @@ -638,7 +651,7 @@ export function ChatsScreen({ } aria-pressed={isWorkPanelOpen} data-testid="toggle-work-panel" - onClick={() => setWorkPanelPreference(!isWorkPanelOpen)} + onClick={() => handleWorkPanelPreference(!isWorkPanelOpen)} size="icon" type="button" variant="ghost" diff --git a/desktop/src/shared/styles/globals/animations.css b/desktop/src/shared/styles/globals/animations.css index b1efedbb2d..1b8940f78e 100644 --- a/desktop/src/shared/styles/globals/animations.css +++ b/desktop/src/shared/styles/globals/animations.css @@ -233,19 +233,18 @@ 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)) 40%, + hsl(var(--primary)) 18%, hsl(var(--background)) ); --buzz-shimmer-band: 300%; color: inherit; } -.dark .buzz-shimmer-accent { - --buzz-shimmer-highlight: color-mix(in oklch, hsl(var(--primary)) 60%, black); -} - /* Glyph recoloring alone is too subtle at sidebar sizes — sweep a translucent highlight WASH behind the text as well (same keyframes, so band and wash move together). The wash is a soft primary tint the text @@ -256,7 +255,7 @@ 90deg, transparent 0%, transparent 40%, - hsl(var(--primary) / 0.28) 50%, + hsl(var(--primary) / 0.16) 50%, transparent 60%, transparent 100% ); From d416f45ca8d2e1d61c29ccd8b241ea2e12ac753d Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 09:12:32 +0100 Subject: [PATCH 65/68] Shimmer: drop the wash rectangle, band sits at the background color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The highlight wash read as a gray slab over the text. The accent shimmer is now glyph-only again, with the band mixed to 8% primary / 92% background — characters wash out to (nearly) the background color as it sweeps, which is the highest-contrast treatment against full-color text in both themes. Co-Authored-By: Claude Fable 5 --- .../src/shared/styles/globals/animations.css | 32 +------------------ 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/desktop/src/shared/styles/globals/animations.css b/desktop/src/shared/styles/globals/animations.css index 1b8940f78e..2d11f0640a 100644 --- a/desktop/src/shared/styles/globals/animations.css +++ b/desktop/src/shared/styles/globals/animations.css @@ -238,43 +238,13 @@ which is the strongest possible contrast against full-color text. */ --buzz-shimmer-highlight: color-mix( in oklab, - hsl(var(--primary)) 18%, + hsl(var(--primary)) 8%, hsl(var(--background)) ); --buzz-shimmer-band: 300%; color: inherit; } -/* Glyph recoloring alone is too subtle at sidebar sizes — sweep a - translucent highlight WASH behind the text as well (same keyframes, so - band and wash move together). The wash is a soft primary tint the text - stays readable through, like a highlighter pass. */ -.buzz-shimmer-accent::after { - animation: buzz-shimmer var(--buzz-shimmer-duration) linear infinite; - background-image: linear-gradient( - 90deg, - transparent 0%, - transparent 40%, - hsl(var(--primary) / 0.16) 50%, - transparent 60%, - transparent 100% - ); - background-repeat: no-repeat; - background-size: var(--buzz-shimmer-band) 100%; - border-radius: 0.25rem; - content: ""; - inset: -0.125rem -0.25rem; - pointer-events: none; - position: absolute; -} - -@media (prefers-reduced-motion: reduce) { - .buzz-shimmer-accent::after { - animation: none; - content: none; - } -} - @media (prefers-reduced-motion: reduce) { .buzz-shimmer, .shimmer { From 7cf50584f2fac0b12a30c0edfcbdf18bea342833 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 09:14:50 +0100 Subject: [PATCH 66/68] Work drawer state is purely remembered Auto-open-on-PR kept re-deriving the drawer state on every chat switch, re-animating it open for anyone who had never touched the toggle. The drawer is now a plain persisted boolean (default closed): it renders in the remembered state on every chat with no animation, and only a deliberate toggle changes it. Co-Authored-By: Claude Fable 5 --- desktop/src/features/chats/ui/ChatsScreen.tsx | 6 +++--- desktop/tests/e2e/chats-first-message.spec.ts | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/chats/ui/ChatsScreen.tsx b/desktop/src/features/chats/ui/ChatsScreen.tsx index 5fb4be5441..125d2dce85 100644 --- a/desktop/src/features/chats/ui/ChatsScreen.tsx +++ b/desktop/src/features/chats/ui/ChatsScreen.tsx @@ -338,8 +338,8 @@ export function ChatsScreen({ return null; }, [messages]); // The drawer's open state is a single persisted preference — switching - // chats must NOT reset it (the panel used to re-animate open on every - // switch). null = never toggled: auto-open when the chat has a PR. + // 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 >(() => { @@ -358,7 +358,7 @@ export function ChatsScreen({ // Best-effort persistence. } }, []); - const isWorkPanelOpen = workPanelPreference ?? agentPullRequestHref !== null; + const isWorkPanelOpen = workPanelPreference ?? false; const startManagedAgentMutation = useStartManagedAgentMutation(); const [isEnsuringDefaultAgent, setIsEnsuringDefaultAgent] = React.useState(false); diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 116d84bf2b..d197f3144a 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -184,7 +184,11 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { await expect( page.locator("[data-link-preview='github-pull-request-agent']"), ).toHaveCount(1, { timeout: 10_000 }); + // The drawer is purely a remembered preference (default closed) — open + // it explicitly; the choice persists across chats and restarts. const workPanel = page.getByTestId("chat-work-panel"); + await expect(workPanel).not.toBeVisible(); + await page.getByTestId("toggle-work-panel").click(); await expect(workPanel).toBeVisible(); await expect(workPanel).toContainText("kennylopez-chatmode"); await expect( From 884ffc5123fa99cb79f62ecc5a65bbc7aa77fcc0 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 5 Jul 2026 09:38:04 +0100 Subject: [PATCH 67/68] Pin a PR to the work panel manually MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inverse of Unlink: when the panel shows nothing (agent never posted the link, discovery found no match) or the wrong PR, "Pin a pull request…" / "Not this chat's PR? Change" opens an inline URL input. A manual pin outranks every automatic source — posted links, remembered auto pins, and branch discovery — and the auto-pin effect never downgrades it. Pins store {href, manual} (older bare-string entries still parse). Co-Authored-By: Claude Fable 5 --- .../features/chats/lib/chatWorkAutomation.ts | 35 ++++- .../src/features/chats/ui/ChatWorkPanel.tsx | 120 +++++++++++++++--- desktop/tests/e2e/chats-first-message.spec.ts | 31 +++++ 3 files changed, 161 insertions(+), 25 deletions(-) diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts index 11b00fad11..f3989e6c8d 100644 --- a/desktop/src/features/chats/lib/chatWorkAutomation.ts +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -151,12 +151,23 @@ const PR_STORAGE_PREFIX = "buzz:chat-work-pr:v1"; * worktree to the same PR — the pin keeps each chat on the PR it actually * resolved first, with posted links always overriding. */ -export function readChatPinnedPr(chatId: string): string | null { +export function readChatPinnedPr(chatId: string): ChatPinnedPr | null { if (typeof window === "undefined") { return null; } try { - return window.localStorage.getItem(`${PR_STORAGE_PREFIX}:${chatId}`); + const raw = window.localStorage.getItem(`${PR_STORAGE_PREFIX}:${chatId}`); + if (raw === null) { + return null; + } + // Older entries stored the bare href string. + if (!raw.startsWith("{")) { + return { href: raw, manual: raw === CHAT_PR_UNPINNED }; + } + const parsed = JSON.parse(raw) as Partial; + return typeof parsed.href === "string" + ? { href: parsed.href, manual: Boolean(parsed.manual) } + : null; } catch { return null; } @@ -168,12 +179,28 @@ export function readChatPinnedPr(chatId: string): string | null { */ export const CHAT_PR_UNPINNED = ""; -export function writeChatPinnedPr(chatId: string, href: string) { +export type ChatPinnedPr = { + href: string; + /** + * True when the user pinned (or unlinked) explicitly — a manual pin + * outranks every automatic source, including links posted in the chat. + */ + manual: boolean; +}; + +export function writeChatPinnedPr( + chatId: string, + href: string, + manual = false, +) { if (typeof window === "undefined") { return; } try { - window.localStorage.setItem(`${PR_STORAGE_PREFIX}:${chatId}`, href); + window.localStorage.setItem( + `${PR_STORAGE_PREFIX}:${chatId}`, + JSON.stringify({ href, manual }), + ); } catch { // Best-effort. } diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 4904d3555a..26f33049dd 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -8,10 +8,12 @@ import { GitBranch, LoaderCircle, MessageSquareText, + Pin, } from "lucide-react"; import { CHAT_PR_UNPINNED, + type ChatPinnedPr, readChatPinnedPr, updateChatWorkAutomation, useChatWorkAutomation, @@ -28,6 +30,8 @@ import { 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"; @@ -86,35 +90,58 @@ export function ChatWorkPanel({ // hidden panel stops polling entirely. const monitorActive = open || automation.autoFixCi || automation.addressComments; - // Pin resolution order: a link posted in THIS chat wins, then the chat's - // previously pinned PR, then branch discovery — discovery alone is - // ambiguous when agents reuse a worktree across chats in one project. - const [pinnedHref, setPinnedHref] = React.useState(() => + // 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 [pinned, setPinned] = React.useState(() => readChatPinnedPr(chatId), ); React.useEffect(() => { - setPinnedHref(readChatPinnedPr(chatId)); + setPinned(readChatPinnedPr(chatId)); }, [chatId]); // The empty-string sentinel means "user unlinked — no PR for this chat": // discovery stays off, posted links still win. - const isUnpinned = pinnedHref === CHAT_PR_UNPINNED; + const isUnpinned = pinned?.href === CHAT_PR_UNPINNED && pinned.manual; const discoveredPrQuery = useGithubPrForBranchQuery( - monitorActive && !prHref && pinnedHref === null ? projectPath : null, + monitorActive && !prHref && pinned === null ? projectPath : null, branch, ); + const manualHref = pinned?.manual && pinned.href ? pinned.href : null; const effectiveHref = + manualHref ?? prHref ?? - (isUnpinned ? null : (pinnedHref ?? discoveredPrQuery.data ?? null)); + (isUnpinned + ? null + : ((pinned?.href || null) ?? discoveredPrQuery.data ?? null)); React.useEffect(() => { - if (effectiveHref && effectiveHref !== readChatPinnedPr(chatId)) { + // Remember what the chat resolved to — but never downgrade a manual pin. + const current = readChatPinnedPr(chatId); + if (current?.manual) { + return; + } + if (effectiveHref && effectiveHref !== current?.href) { writeChatPinnedPr(chatId, effectiveHref); - setPinnedHref(effectiveHref); + setPinned({ href: effectiveHref, manual: false }); } }, [chatId, effectiveHref]); const handleUnlinkPr = React.useCallback(() => { - writeChatPinnedPr(chatId, CHAT_PR_UNPINNED); - setPinnedHref(CHAT_PR_UNPINNED); + writeChatPinnedPr(chatId, CHAT_PR_UNPINNED, true); + setPinned({ 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); + setPinned({ href: trimmed, manual: true }); + setIsPinEditorOpen(false); + setPinInput(""); + }, [chatId, pinInput]); const preview = effectiveHref ? parseSupportedLinkPreview(effectiveHref) : null; @@ -275,15 +302,26 @@ export function ChatWorkPanel({ key={preview.href} > - {!prHref ? ( - + {manualHref || !prHref ? ( +
+ + + +
) : null}
) : null} + {isPinEditorOpen ? ( +
{ + event.preventDefault(); + handlePinSubmit(); + }} + > + setPinInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + setIsPinEditorOpen(false); + setPinInput(""); + } + }} + placeholder="https://github.com/owner/repo/pull/123" + value={pinInput} + /> + +
+ ) : !preview ? ( + + ) : null} diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index d197f3144a..9ff146f764 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -288,6 +288,37 @@ test("new chat screen shows agent, directory, and invite preset cards", async ({ await page.screenshot({ path: "test-results/chat-start-presets.png" }); }); +test("a PR can be pinned to the work panel manually", async ({ page }) => { + await installMockBridge(page); + await page.goto("/#/chats"); + const composer = page.locator("[contenteditable='true'], textarea").first(); + await expect(composer).toBeVisible(); + await composer.click(); + await composer.fill("pin a pr here"); + await composer.press("Enter"); + await expect(page).toHaveURL(/\/chats\/.+/); + + await page.getByTestId("toggle-work-panel").click(); + const workPanel = page.getByTestId("chat-work-panel"); + await expect(workPanel).toBeVisible(); + + await page.getByTestId("chat-work-pin-pr").click(); + await page + .getByTestId("chat-work-pin-input") + .fill("https://github.com/block/buzz/pull/1460"); + await page.getByRole("button", { name: "Pin", exact: true }).click(); + await expect( + workPanel.locator("[data-link-preview='github-pull-request']"), + ).toBeVisible({ timeout: 10_000 }); + + // Unlink clears it back to the empty state. + await page.getByTestId("chat-work-unlink-pr").click(); + await expect( + workPanel.locator("[data-link-preview='github-pull-request']"), + ).toHaveCount(0); + await expect(page.getByTestId("chat-work-pin-pr")).toBeVisible(); +}); + test("sidebar chat title shimmers while the agent has an active turn", async ({ page, }) => { From 993f3d90e94f4443f54c5399a9dcdf4f285460e7 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 7 Jul 2026 08:20:32 +0100 Subject: [PATCH 68/68] Polish chat mode work context --- crates/buzz-db/src/migration.rs | 5 +- .../src/features/agents/agentWorkingSignal.ts | 40 +- .../src/features/chats/lib/chatLink.test.mjs | 19 + desktop/src/features/chats/lib/chatLink.ts | 28 ++ .../chats/lib/chatOpenDestination.test.mjs | 43 ++ .../features/chats/lib/chatOpenDestination.ts | 29 ++ .../src/features/chats/lib/chatSetup.test.mjs | 17 + desktop/src/features/chats/lib/chatSetup.ts | 40 ++ .../features/chats/lib/chatWorkAutomation.ts | 41 +- .../chats/lib/chatWorkBinding.test.mjs | 170 ++++++++ .../src/features/chats/lib/chatWorkBinding.ts | 310 ++++++++++++++ .../chats/lib/chatWorkBranch.test.mjs | 89 +++- .../src/features/chats/lib/chatWorkBranch.ts | 80 +++- .../features/chats/lib/chatWorkLinks.test.mjs | 83 ++++ .../src/features/chats/lib/chatWorkLinks.ts | 61 +++ .../chats/ui/ChatConversationRows.tsx | 33 +- desktop/src/features/chats/ui/ChatDetail.tsx | 402 ++++++++++++++---- .../features/chats/ui/ChatHeaderActions.tsx | 31 +- .../src/features/chats/ui/ChatWorkPanel.tsx | 87 +++- desktop/src/features/chats/ui/ChatsScreen.tsx | 28 +- desktop/src/shared/ui/markdown.tsx | 31 +- desktop/src/shared/useMessageDeepLinks.ts | 28 +- 22 files changed, 1499 insertions(+), 196 deletions(-) create mode 100644 desktop/src/features/chats/lib/chatOpenDestination.test.mjs create mode 100644 desktop/src/features/chats/lib/chatOpenDestination.ts create mode 100644 desktop/src/features/chats/lib/chatWorkBinding.test.mjs create mode 100644 desktop/src/features/chats/lib/chatWorkBinding.ts create mode 100644 desktop/src/features/chats/lib/chatWorkLinks.test.mjs create mode 100644 desktop/src/features/chats/lib/chatWorkLinks.ts diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index f76fd36708..00e95a2745 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -483,7 +483,10 @@ mod tests { .sql .as_str() .contains("CREATE TABLE scheduled_workflow_fires")); - assert!(migrations[0].sql.as_str().contains("CREATE TABLE audit_log")); + assert!(migrations[0] + .sql + .as_str() + .contains("CREATE TABLE audit_log")); assert!(migrations[0] .sql .as_str() 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/chats/lib/chatLink.test.mjs b/desktop/src/features/chats/lib/chatLink.test.mjs index 35a21b0246..8ffc654bd8 100644 --- a/desktop/src/features/chats/lib/chatLink.test.mjs +++ b/desktop/src/features/chats/lib/chatLink.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { buildChatLink, + buildDualOpenChatLink, isChatLink, parseChatLink, parseChatRouteLink, @@ -30,6 +31,24 @@ test("buildChatLink omits blank titles", () => { ); }); +test("buildDualOpenChatLink uses a message link when a fallback message exists", () => { + assert.equal( + buildDualOpenChatLink({ + chatId: CHAT, + fallbackMessageId: "event-123", + title: "Corner radius notes", + }), + `buzz://message?channel=${CHAT}&id=event-123&chat=1&title=Corner+radius+notes`, + ); +}); + +test("buildDualOpenChatLink falls back to chat links without a message anchor", () => { + assert.equal( + buildDualOpenChatLink({ chatId: CHAT, title: "Corner radius notes" }), + `buzz://chat?channel=${CHAT}&title=Corner+radius+notes`, + ); +}); + test("buildChatLink rejects missing chat id", () => { assert.throws(() => buildChatLink({ chatId: "" })); }); diff --git a/desktop/src/features/chats/lib/chatLink.ts b/desktop/src/features/chats/lib/chatLink.ts index 5ecbb43e93..4f5f22fda9 100644 --- a/desktop/src/features/chats/lib/chatLink.ts +++ b/desktop/src/features/chats/lib/chatLink.ts @@ -13,6 +13,10 @@ export type ChatLinkInput = { title?: string | null; }; +export type DualOpenChatLinkInput = ChatLinkInput & { + fallbackMessageId?: string | null; +}; + type ParseChatRouteLinkOptions = { currentOrigin?: string | null; }; @@ -40,6 +44,30 @@ export function buildChatLink(input: ChatLinkInput): string { return `${CHAT_LINK_SCHEME}//${CHAT_LINK_HOST}?${params.toString()}`; } +/** + * Build a link that opens as a chat on chat-capable builds and still opens the + * underlying private channel on older builds that only understand + * `buzz://message`. + */ +export function buildDualOpenChatLink(input: DualOpenChatLinkInput): string { + if (!input.fallbackMessageId) { + return buildChatLink(input); + } + if (!input.chatId) { + throw new Error("buildDualOpenChatLink: chatId is required"); + } + + const params = new URLSearchParams(); + params.set("channel", input.chatId); + params.set("id", input.fallbackMessageId); + params.set("chat", "1"); + const title = input.title?.trim(); + if (title) { + params.set("title", title); + } + return `${CHAT_LINK_SCHEME}//message?${params.toString()}`; +} + export function parseChatLink(url: string): ChatLinkParseResult { let parsed: URL; try { diff --git a/desktop/src/features/chats/lib/chatOpenDestination.test.mjs b/desktop/src/features/chats/lib/chatOpenDestination.test.mjs new file mode 100644 index 0000000000..e145360022 --- /dev/null +++ b/desktop/src/features/chats/lib/chatOpenDestination.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveChatOpenDestination } from "./chatOpenDestination.ts"; + +const CHANNEL_ID = "f570339f-8f8a-4e08-a779-8d954aa44109"; + +test("resolveChatOpenDestination opens chat when metadata exists", async () => { + assert.deepEqual( + await resolveChatOpenDestination(CHANNEL_ID, async () => ({ + channelId: CHANNEL_ID, + authorPubkey: null, + title: "Chat", + defaultAgentPubkey: null, + templateId: null, + projectId: null, + projectName: null, + projectPath: null, + projectTemplateId: null, + sourceChannelId: null, + sourceEventId: null, + sourceThreadRootId: null, + updatedAt: 1, + })), + { kind: "chat", chatId: CHANNEL_ID }, + ); +}); + +test("resolveChatOpenDestination falls back to channel without metadata", async () => { + assert.deepEqual( + await resolveChatOpenDestination(CHANNEL_ID, async () => null), + { kind: "channel", channelId: CHANNEL_ID }, + ); +}); + +test("resolveChatOpenDestination falls back to channel on lookup errors", async () => { + assert.deepEqual( + await resolveChatOpenDestination(CHANNEL_ID, async () => { + throw new Error("not a member"); + }), + { kind: "channel", channelId: CHANNEL_ID }, + ); +}); diff --git a/desktop/src/features/chats/lib/chatOpenDestination.ts b/desktop/src/features/chats/lib/chatOpenDestination.ts new file mode 100644 index 0000000000..566245556d --- /dev/null +++ b/desktop/src/features/chats/lib/chatOpenDestination.ts @@ -0,0 +1,29 @@ +import { getChatMetadata } from "@/shared/api/tauriChats"; +import type { ChatMetadata } from "@/shared/api/types"; + +export type ChatOpenDestination = + | { kind: "chat"; chatId: string } + | { kind: "channel"; channelId: string }; + +type MetadataLookup = (channelId: string) => Promise; + +/** + * Chat links carry the underlying private channel id. Builds that understand + * chat metadata should open the Chats surface; otherwise the same id still + * works as a private channel route. + */ +export async function resolveChatOpenDestination( + channelId: string, + lookup: MetadataLookup = getChatMetadata, +): Promise { + try { + const metadata = await lookup(channelId); + if (metadata) { + return { kind: "chat", chatId: channelId }; + } + } catch { + // Missing access/metadata should degrade to the ordinary channel route, + // which can show the existing private-channel access state. + } + return { kind: "channel", channelId }; +} diff --git a/desktop/src/features/chats/lib/chatSetup.test.mjs b/desktop/src/features/chats/lib/chatSetup.test.mjs index 744281970c..f8f042b6d5 100644 --- a/desktop/src/features/chats/lib/chatSetup.test.mjs +++ b/desktop/src/features/chats/lib/chatSetup.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { buildProjectSetupContext, + deriveBranchTitle, deriveChatTitle, deriveConversationTitle, uniqueMentionPubkeys, @@ -67,6 +68,22 @@ test("deriveConversationTitle falls back when nothing survives", () => { assert.equal(deriveConversationTitle(" "), "New chat"); }); +test("deriveBranchTitle turns owner-prefixed branches into readable titles", () => { + assert.equal(deriveBranchTitle("kennylopez-welcome-icon"), "Welcome icon"); + assert.equal(deriveBranchTitle("origin/kennylopez-chat-mode"), "Chat mode"); + assert.equal( + deriveBranchTitle("refs/heads/kennethlopez-fix-activate-agent-banner"), + "Activate agent banner", + ); +}); + +test("deriveBranchTitle strips common work prefixes and ticket keys", () => { + assert.equal(deriveBranchTitle("feat/spellcheck"), "Spellcheck"); + assert.equal(deriveBranchTitle("fix/panel-width"), "Panel width"); + assert.equal(deriveBranchTitle("ABC-123-copy-link-mode"), "Copy link mode"); + assert.equal(deriveBranchTitle("wip"), null); +}); + test("uniqueMentionPubkeys adds default agent and removes sender", () => { const self = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; diff --git a/desktop/src/features/chats/lib/chatSetup.ts b/desktop/src/features/chats/lib/chatSetup.ts index 97d4a46b49..3e0730732a 100644 --- a/desktop/src/features/chats/lib/chatSetup.ts +++ b/desktop/src/features/chats/lib/chatSetup.ts @@ -96,6 +96,46 @@ export function deriveConversationTitle(content: string) { return text.charAt(0).toUpperCase() + text.slice(1); } +const OWNER_BRANCH_PREFIXES = ["kennylopez", "kennethlopez", "kenneth"]; +const WORK_BRANCH_PREFIX_PATTERN = + /^(?:feat|feature|fix|bugfix|bug|chore|docs?|tests?|refactor|cleanup|wip|hotfix|release|task|work)[-_.]+/i; + +export function deriveBranchTitle(branch: string | null | undefined) { + let text = branch?.trim() ?? ""; + if (!text) { + return null; + } + + text = text + .replace(/^refs\/heads\//i, "") + .replace(/^remotes\//i, "") + .replace(/^origin\//i, ""); + text = text.split("/").filter(Boolean).pop() ?? text; + + for (const prefix of OWNER_BRANCH_PREFIXES) { + const pattern = new RegExp(`^${prefix}[-_.]+`, "i"); + text = text.replace(pattern, ""); + } + + let previous = ""; + while (previous !== text) { + previous = text; + text = text.replace(WORK_BRANCH_PREFIX_PATTERN, ""); + } + + text = text + .replace(/^[A-Z]+-\d+[-_.]+/i, "") + .replace(/[-_.]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + + if (text.length < MIN_CONVERSATION_TITLE_CHARS) { + return null; + } + + return text.charAt(0).toUpperCase() + text.slice(1); +} + export function uniqueMentionPubkeys( identityPubkey: string | undefined, mentionPubkeys: string[], diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts index f3989e6c8d..be4fb37046 100644 --- a/desktop/src/features/chats/lib/chatWorkAutomation.ts +++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts @@ -112,9 +112,15 @@ export function updateChatWorkAutomation( } export function useChatWorkAutomation(chatId: string): ChatWorkAutomation { - const [state, setState] = React.useState(() => - readChatWorkAutomation(chatId), - ); + const [state, setState] = React.useState<{ + chatId: string; + automation: ChatWorkAutomation; + }>(() => ({ + chatId, + automation: readChatWorkAutomation(chatId), + })); + const automation = + state.chatId === chatId ? state.automation : readChatWorkAutomation(chatId); React.useEffect(() => { // Content-compare: a fresh object per storage event would re-run every @@ -122,14 +128,16 @@ export function useChatWorkAutomation(chatId: string): ChatWorkAutomation { const refresh = () => setState((current) => { const next = readChatWorkAutomation(chatId); - return current.autoFixCi === next.autoFixCi && - current.addressComments === next.addressComments && - current.lastCiNudgeSha === next.lastCiNudgeSha && - current.lastCommentNudgeCount === next.lastCommentNudgeCount && - current.lastCiNudgeAt === next.lastCiNudgeAt && - current.lastCommentNudgeAt === next.lastCommentNudgeAt + return current.chatId === chatId && + current.automation.autoFixCi === next.autoFixCi && + current.automation.addressComments === next.addressComments && + current.automation.lastCiNudgeSha === next.lastCiNudgeSha && + current.automation.lastCommentNudgeCount === + next.lastCommentNudgeCount && + current.automation.lastCiNudgeAt === next.lastCiNudgeAt && + current.automation.lastCommentNudgeAt === next.lastCommentNudgeAt ? current - : next; + : { chatId, automation: next }; }); refresh(); window.addEventListener(STORAGE_EVENT, refresh); @@ -140,7 +148,7 @@ export function useChatWorkAutomation(chatId: string): ChatWorkAutomation { }; }, [chatId]); - return state; + return automation; } const PR_STORAGE_PREFIX = "buzz:chat-work-pr:v1"; @@ -205,3 +213,14 @@ export function writeChatPinnedPr( // Best-effort. } } + +export function clearChatPinnedPr(chatId: string) { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.removeItem(`${PR_STORAGE_PREFIX}:${chatId}`); + } catch { + // Best-effort. + } +} diff --git a/desktop/src/features/chats/lib/chatWorkBinding.test.mjs b/desktop/src/features/chats/lib/chatWorkBinding.test.mjs new file mode 100644 index 0000000000..3a161e1d09 --- /dev/null +++ b/desktop/src/features/chats/lib/chatWorkBinding.test.mjs @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildChatWorkContext, + mergeChatWorkBinding, + shouldShowChatWorkContextContent, +} from "./chatWorkBinding.ts"; + +test("mergeChatWorkBinding fills an empty binding", () => { + assert.deepEqual( + mergeChatWorkBinding( + null, + { + projectName: "Spellcheck", + projectPath: " /Users/k/Development/sprout-spellcheck ", + branch: " feat/spellcheck ", + prHref: " https://github.com/block/buzz/pull/1515 ", + }, + { now: 123 }, + ), + { + projectName: "Spellcheck", + projectPath: "/Users/k/Development/sprout-spellcheck", + branch: "feat/spellcheck", + prHref: "https://github.com/block/buzz/pull/1515", + prDetached: false, + updatedAt: 123, + }, + ); +}); + +test("automatic hints do not replace an existing branch or PR", () => { + const current = mergeChatWorkBinding( + null, + { + branch: "feat/spellcheck", + prHref: "https://github.com/block/buzz/pull/1515", + }, + { now: 1 }, + ); + + assert.deepEqual( + mergeChatWorkBinding( + current, + { + branch: "kennylopez-dictation", + prHref: "https://github.com/block/buzz/pull/1511", + }, + { now: 2 }, + ), + { + projectName: null, + projectPath: null, + branch: "feat/spellcheck", + prHref: "https://github.com/block/buzz/pull/1515", + prDetached: false, + updatedAt: 2, + }, + ); +}); + +test("manual PR replacement can switch the bound PR", () => { + const current = mergeChatWorkBinding( + null, + { prHref: "https://github.com/block/buzz/pull/1515" }, + { now: 1 }, + ); + + assert.equal( + mergeChatWorkBinding( + current, + { prHref: "https://github.com/block/buzz/pull/1511" }, + { now: 2, replacePr: true }, + )?.prHref, + "https://github.com/block/buzz/pull/1511", + ); +}); + +test("detached PR blocks later automatic PR hints", () => { + const detached = mergeChatWorkBinding( + null, + { branch: "feat/spellcheck", prHref: null, prDetached: true }, + { now: 1, replacePr: true }, + ); + + assert.deepEqual( + mergeChatWorkBinding( + detached, + { prHref: "https://github.com/block/buzz/pull/1511" }, + { now: 2 }, + ), + { + projectName: null, + projectPath: null, + branch: "feat/spellcheck", + prHref: null, + prDetached: true, + updatedAt: 2, + }, + ); +}); + +test("buildChatWorkContext describes the isolated branch and PR", () => { + const content = buildChatWorkContext({ + projectName: "Spellcheck", + projectPath: "/Users/k/Development/sprout-spellcheck", + branch: "feat/spellcheck", + prHref: "https://github.com/block/buzz/pull/1515", + prDetached: false, + updatedAt: 1, + }); + + assert.match(content ?? "", /^Work context\nScope: this chat is isolated/m); + assert.match(content ?? "", /Project: Spellcheck/); + assert.match(content ?? "", /Branch: feat\/spellcheck/); + assert.match( + content ?? "", + /Pull request: https:\/\/github\.com\/block\/buzz\/pull\/1515/, + ); +}); + +test("buildChatWorkContext skips project-only bindings", () => { + assert.equal( + buildChatWorkContext({ + projectName: "Spellcheck", + projectPath: "/Users/k/Development/sprout-spellcheck", + branch: null, + prHref: null, + prDetached: false, + updatedAt: 1, + }), + null, + ); +}); + +test("shouldShowChatWorkContextContent keeps historical work context rows", () => { + const spellcheckContext = buildChatWorkContext({ + projectName: "Spellcheck", + projectPath: "/Users/k/Development/sprout-spellcheck", + branch: "feat/spellcheck", + prHref: "https://github.com/block/buzz/pull/1515", + prDetached: false, + updatedAt: 1, + }); + const dictationContext = buildChatWorkContext({ + projectName: "Dictation", + projectPath: "/Users/k/Development/sprout-dictation", + branch: "kennylopez-dictation", + prHref: "https://github.com/block/buzz/pull/1511", + prDetached: false, + updatedAt: 1, + }); + + assert.equal( + shouldShowChatWorkContextContent(dictationContext, spellcheckContext), + true, + ); + assert.equal( + shouldShowChatWorkContextContent(spellcheckContext, spellcheckContext), + true, + ); + assert.equal( + shouldShowChatWorkContextContent( + "Project setup\nFolder: /tmp/project", + null, + ), + true, + ); +}); diff --git a/desktop/src/features/chats/lib/chatWorkBinding.ts b/desktop/src/features/chats/lib/chatWorkBinding.ts new file mode 100644 index 0000000000..fd03e9c6eb --- /dev/null +++ b/desktop/src/features/chats/lib/chatWorkBinding.ts @@ -0,0 +1,310 @@ +import * as React from "react"; + +const STORAGE_PREFIX = "buzz:chat-work-binding:v2"; +const STORAGE_EVENT = "buzz:chat-work-binding-changed"; +const CONTEXT_SENT_PREFIX = "buzz:chat-work-context-sent:v2"; + +export type ChatWorkBinding = { + projectName: string | null; + projectPath: string | null; + branch: string | null; + prHref: string | null; + /** + * True when the user explicitly disconnected the chat from PR monitoring. + * Automatic PR discovery/link parsing must not repopulate `prHref` while + * this is set. + */ + prDetached: boolean; + updatedAt: number; +}; + +export type ChatWorkBindingPatch = Partial< + Pick< + ChatWorkBinding, + "projectName" | "projectPath" | "branch" | "prHref" | "prDetached" + > +>; + +export type MergeChatWorkBindingOptions = { + now?: number; + replaceProject?: boolean; + replaceBranch?: boolean; + replacePr?: boolean; +}; + +const EMPTY_BINDING: ChatWorkBinding = { + projectName: null, + projectPath: null, + branch: null, + prHref: null, + prDetached: false, + updatedAt: 0, +}; + +function storageKey(chatId: string) { + return `${STORAGE_PREFIX}:${chatId}`; +} + +function contextSentKey(chatId: string) { + return `${CONTEXT_SENT_PREFIX}:${chatId}`; +} + +function hasOwn(value: T, key: keyof T) { + return Object.keys(value).includes(String(key)); +} + +function cleanString(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function normalizeBinding(value: Partial): ChatWorkBinding { + return { + projectName: cleanString(value.projectName), + projectPath: cleanString(value.projectPath), + branch: cleanString(value.branch), + prHref: cleanString(value.prHref), + prDetached: Boolean(value.prDetached), + updatedAt: Number.isFinite(value.updatedAt) ? Number(value.updatedAt) : 0, + }; +} + +export function isEmptyChatWorkBinding(binding: ChatWorkBinding | null) { + return ( + binding === null || + (!binding.projectName && + !binding.projectPath && + !binding.branch && + !binding.prHref && + !binding.prDetached) + ); +} + +function bindingsEqual( + left: ChatWorkBinding | null, + right: ChatWorkBinding | null, +) { + if (left === right) { + return true; + } + if (!left || !right) { + return false; + } + return ( + left.projectName === right.projectName && + left.projectPath === right.projectPath && + left.branch === right.branch && + left.prHref === right.prHref && + left.prDetached === right.prDetached + ); +} + +export function mergeChatWorkBinding( + current: ChatWorkBinding | null, + patch: ChatWorkBindingPatch, + options: MergeChatWorkBindingOptions = {}, +): ChatWorkBinding | null { + const now = options.now ?? Date.now(); + const next: ChatWorkBinding = { + ...(current ?? EMPTY_BINDING), + updatedAt: current?.updatedAt ?? 0, + }; + + if (options.replaceProject || !current?.projectName) { + if (hasOwn(patch, "projectName")) { + next.projectName = cleanString(patch.projectName); + } + } + if (options.replaceProject || !current?.projectPath) { + if (hasOwn(patch, "projectPath")) { + next.projectPath = cleanString(patch.projectPath); + } + } + + if (options.replaceBranch || !current?.branch) { + if (hasOwn(patch, "branch")) { + next.branch = cleanString(patch.branch); + } + } + + if (options.replacePr || (!current?.prHref && !current?.prDetached)) { + if (hasOwn(patch, "prHref")) { + next.prHref = cleanString(patch.prHref); + } + if (hasOwn(patch, "prDetached")) { + next.prDetached = Boolean(patch.prDetached); + } else if (next.prHref) { + next.prDetached = false; + } + } + + const normalized = normalizeBinding({ ...next, updatedAt: now }); + return isEmptyChatWorkBinding(normalized) ? null : normalized; +} + +export function readChatWorkBinding(chatId: string): ChatWorkBinding | null { + if (typeof window === "undefined") { + return null; + } + try { + const raw = window.localStorage.getItem(storageKey(chatId)); + if (!raw) { + return null; + } + const normalized = normalizeBinding( + JSON.parse(raw) as Partial, + ); + return isEmptyChatWorkBinding(normalized) ? null : normalized; + } catch { + return null; + } +} + +export function writeChatWorkBinding( + chatId: string, + binding: ChatWorkBinding | null, +) { + if (typeof window === "undefined") { + return; + } + try { + if (isEmptyChatWorkBinding(binding)) { + window.localStorage.removeItem(storageKey(chatId)); + } else { + window.localStorage.setItem(storageKey(chatId), JSON.stringify(binding)); + } + window.dispatchEvent(new CustomEvent(STORAGE_EVENT)); + } catch { + // Chat work binding is local convenience state; ignore storage failures. + } +} + +export function updateChatWorkBinding( + chatId: string, + patch: ChatWorkBindingPatch, + options?: MergeChatWorkBindingOptions, +) { + const current = readChatWorkBinding(chatId); + const next = mergeChatWorkBinding(current, patch, options); + if (!bindingsEqual(current, next)) { + writeChatWorkBinding(chatId, next); + } + return next; +} + +export function clearChatWorkBindingPr(chatId: string, href?: string | null) { + const current = readChatWorkBinding(chatId); + if (!current) { + return null; + } + if (href && current.prHref !== href) { + return current; + } + return updateChatWorkBinding( + chatId, + { prHref: null, prDetached: false }, + { replacePr: true }, + ); +} + +type ChatWorkBindingState = { + chatId: string | null; + binding: ChatWorkBinding | null; +}; + +export function useChatWorkBinding(chatId: string | null | undefined) { + const normalizedChatId = chatId ?? null; + const [state, setState] = React.useState(() => ({ + chatId: normalizedChatId, + binding: normalizedChatId ? readChatWorkBinding(normalizedChatId) : null, + })); + + const currentBinding = + state.chatId === normalizedChatId + ? state.binding + : normalizedChatId + ? readChatWorkBinding(normalizedChatId) + : null; + + React.useEffect(() => { + const refresh = () => { + const next = normalizedChatId + ? readChatWorkBinding(normalizedChatId) + : null; + setState((current) => + current.chatId === normalizedChatId && + bindingsEqual(current.binding, next) + ? current + : { chatId: normalizedChatId, binding: next }, + ); + }; + refresh(); + window.addEventListener(STORAGE_EVENT, refresh); + window.addEventListener("storage", refresh); + return () => { + window.removeEventListener(STORAGE_EVENT, refresh); + window.removeEventListener("storage", refresh); + }; + }, [normalizedChatId]); + + return currentBinding; +} + +export function buildChatWorkContext(binding: ChatWorkBinding | null) { + if (!binding || (!binding.branch && !binding.prHref)) { + return null; + } + + const lines = [ + "Work context", + "Scope: this chat is isolated to the work item below. Do not use branches, pull requests, or status from other chats unless the user explicitly asks to switch.", + ]; + if (binding.projectName) { + lines.push(`Project: ${binding.projectName}`); + } + if (binding.projectPath) { + lines.push(`Folder: ${binding.projectPath}`); + } + if (binding.branch) { + lines.push(`Branch: ${binding.branch}`); + } + if (binding.prHref) { + lines.push(`Pull request: ${binding.prHref}`); + } + return lines.join("\n"); +} + +export function isChatWorkContextContent(content: string) { + return content.startsWith("Work context"); +} + +export function shouldShowChatWorkContextContent( + _content: string, + _currentWorkContext: string | null, +) { + // Work context rows are persisted chat history. A newer branch/PR binding + // should add another row, not hide the older one and make the card look + // pinned to the bottom. + return true; +} + +export function wasChatWorkContextSent(chatId: string, content: string) { + if (typeof window === "undefined") { + return false; + } + try { + return window.localStorage.getItem(contextSentKey(chatId)) === content; + } catch { + return false; + } +} + +export function markChatWorkContextSent(chatId: string, content: string) { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.setItem(contextSentKey(chatId), content); + } catch { + // Best effort. + } +} diff --git a/desktop/src/features/chats/lib/chatWorkBranch.test.mjs b/desktop/src/features/chats/lib/chatWorkBranch.test.mjs index e92de836a3..667c8de5e0 100644 --- a/desktop/src/features/chats/lib/chatWorkBranch.test.mjs +++ b/desktop/src/features/chats/lib/chatWorkBranch.test.mjs @@ -2,8 +2,12 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + collectBranchSourcesFromAgentMessages, + collectChatWorkBranchSources, deriveBranchFromAgentMessages, + deriveBranchSourceFromAgentMessages, deriveChatWorkBranch, + deriveChatWorkBranchSource, parseBranchFromCommand, } from "./chatWorkBranch.ts"; @@ -20,8 +24,8 @@ function toolItem(command, overrides = {}) { args: { command }, result: "", isError: false, - timestamp: "2026-07-04T00:00:00Z", - startedAt: "2026-07-04T00:00:00Z", + timestamp: overrides.timestamp ?? "2026-07-04T00:00:00Z", + startedAt: overrides.timestamp ?? "2026-07-04T00:00:00Z", completedAt: null, channelId: overrides.channelId ?? "chat-1", turnId: overrides.turnId ?? "turn-1", @@ -89,6 +93,34 @@ test("deriveChatWorkBranch returns the latest branch across the transcript", () assert.equal(deriveChatWorkBranch(transcript), "second-branch"); }); +test("deriveChatWorkBranchSource returns the latest branch timestamp", () => { + const transcript = [ + toolItem("git worktree add ../wt -b first-branch", { + id: "1", + timestamp: "2026-07-04T00:00:01Z", + }), + toolItem("git checkout -b second-branch", { + id: "2", + timestamp: "2026-07-04T00:00:03Z", + }), + ]; + + assert.deepEqual(deriveChatWorkBranchSource(transcript), { + branch: "second-branch", + timestampMs: Date.parse("2026-07-04T00:00:03Z"), + }); + assert.deepEqual(collectChatWorkBranchSources(transcript), [ + { + branch: "first-branch", + timestampMs: Date.parse("2026-07-04T00:00:01Z"), + }, + { + branch: "second-branch", + timestampMs: Date.parse("2026-07-04T00:00:03Z"), + }, + ]); +}); + test("deriveChatWorkBranch is null without branch activity", () => { assert.equal(deriveChatWorkBranch([toolItem("pnpm test")]), null); }); @@ -97,18 +129,33 @@ const AGENT_PK = "cd".repeat(32); test("agent messages announcing a worktree branch are parsed", () => { const messages = [ - { pubkey: "ff".repeat(32), content: "please make a worktree" }, + { + pubkey: "ff".repeat(32), + content: "please make a worktree", + created_at: 100, + }, { pubkey: AGENT_PK, content: "Done! Created a new worktree at /Users/k/Development/sprout-dictation " + "on branch kennylopez-dictation, based off latest main.", + created_at: 200, }, ]; assert.equal( deriveBranchFromAgentMessages(messages, AGENT_PK), "kennylopez-dictation", ); + assert.deepEqual(deriveBranchSourceFromAgentMessages(messages, AGENT_PK), { + branch: "kennylopez-dictation", + timestampMs: 200_000, + }); + assert.deepEqual(collectBranchSourcesFromAgentMessages(messages, AGENT_PK), [ + { + branch: "kennylopez-dictation", + timestampMs: 200_000, + }, + ]); }); test("backticked branch names and quoted commands in messages parse too", () => { @@ -128,6 +175,42 @@ test("backticked branch names and quoted commands in messages parse too", () => ); }); +test("agent messages mentioning multiple branches are ignored as ambiguous", () => { + assert.equal( + deriveBranchFromAgentMessages( + [ + { + pubkey: AGENT_PK, + content: + "Spellcheck is on branch `feat/spellcheck`; dictation is on branch `kennylopez-dictation`.", + }, + ], + AGENT_PK, + ), + null, + ); +}); + +test("ambiguous branch summaries do not overwrite an earlier concrete branch", () => { + const messages = [ + { + pubkey: AGENT_PK, + content: + "Created a new worktree at /Users/k/Development/sprout-spellcheck on branch feat/spellcheck.", + }, + { + pubkey: AGENT_PK, + content: + "Spellcheck is on branch `feat/spellcheck`; dictation is on branch `kennylopez-dictation`.", + }, + ]; + + assert.equal( + deriveBranchFromAgentMessages(messages, AGENT_PK), + "feat/spellcheck", + ); +}); + test("non-agent messages and branchless text derive nothing", () => { assert.equal( deriveBranchFromAgentMessages( diff --git a/desktop/src/features/chats/lib/chatWorkBranch.ts b/desktop/src/features/chats/lib/chatWorkBranch.ts index cae1b2a18e..236bc3d403 100644 --- a/desktop/src/features/chats/lib/chatWorkBranch.ts +++ b/desktop/src/features/chats/lib/chatWorkBranch.ts @@ -11,7 +11,18 @@ import { getToolString } from "@/features/agents/ui/agentSessionUtils"; export function deriveChatWorkBranch( transcript: readonly TranscriptItem[], ): string | null { - let branch: string | null = null; + return deriveChatWorkBranchSource(transcript)?.branch ?? null; +} + +export type ChatWorkBranchSource = { + branch: string; + timestampMs: number | null; +}; + +export function collectChatWorkBranchSources( + transcript: readonly TranscriptItem[], +): ChatWorkBranchSource[] { + const sources: ChatWorkBranchSource[] = []; for (const item of transcript) { if (item.type !== "tool") { continue; @@ -22,10 +33,20 @@ export function deriveChatWorkBranch( } const parsed = parseBranchFromCommand(command); if (parsed) { - branch = parsed; + sources.push({ + branch: parsed, + timestampMs: timestampToMs(item.timestamp), + }); } } - return branch; + return sources; +} + +export function deriveChatWorkBranchSource( + transcript: readonly TranscriptItem[], +): ChatWorkBranchSource | null { + const sources = collectChatWorkBranchSources(transcript); + return sources[sources.length - 1] ?? null; } /** @@ -36,25 +57,48 @@ export function deriveChatWorkBranch( * commands in the text; the last mention across the messages wins. */ export function deriveBranchFromAgentMessages( - messages: readonly { pubkey: string; content: string }[], + messages: readonly { pubkey: string; content: string; created_at?: number }[], agentPubkey: string | null | undefined, ): string | null { + return ( + deriveBranchSourceFromAgentMessages(messages, agentPubkey)?.branch ?? null + ); +} + +export function collectBranchSourcesFromAgentMessages( + messages: readonly { pubkey: string; content: string; created_at?: number }[], + agentPubkey: string | null | undefined, +): ChatWorkBranchSource[] { if (!agentPubkey) { - return null; + return []; } - let branch: string | null = null; + const sources: ChatWorkBranchSource[] = []; for (const message of messages) { if (message.pubkey !== agentPubkey) { continue; } const parsed = parseBranchFromCommand(message.content) ?? - parseBranchFromProse(message.content); + parseSingleBranchFromProse(message.content); if (parsed) { - branch = parsed; + sources.push({ + branch: parsed, + timestampMs: + typeof message.created_at === "number" + ? message.created_at * 1_000 + : null, + }); } } - return branch; + return sources; +} + +export function deriveBranchSourceFromAgentMessages( + messages: readonly { pubkey: string; content: string; created_at?: number }[], + agentPubkey: string | null | undefined, +): ChatWorkBranchSource | null { + const sources = collectBranchSourcesFromAgentMessages(messages, agentPubkey); + return sources[sources.length - 1] ?? null; } const PROSE_BRANCH_PATTERNS = [ @@ -62,8 +106,8 @@ const PROSE_BRANCH_PATTERNS = [ /\bbranch\s+[`'"]([A-Za-z0-9._/-]+)[`'"]/i, ]; -function parseBranchFromProse(text: string): string | null { - let branch: string | null = null; +function parseSingleBranchFromProse(text: string): string | null { + const branches: string[] = []; for (const pattern of PROSE_BRANCH_PATTERNS) { const flags = pattern.flags.includes("g") ? pattern.flags @@ -72,11 +116,13 @@ function parseBranchFromProse(text: string): string | null { for (const match of text.matchAll(global)) { const candidate = match[1]; if (candidate && !looksLikeSha(candidate)) { - branch = candidate; + branches.push(candidate); } } } - return branch; + + const uniqueBranches = [...new Set(branches)]; + return uniqueBranches.length === 1 ? uniqueBranches[0] : null; } /** Latest branch named by any segment of a (possibly compound) command. */ @@ -177,6 +223,14 @@ function looksLikeSha(token: string): boolean { return /^[0-9a-f]{7,40}$/i.test(token); } +function timestampToMs(timestamp: string | undefined): number | null { + if (!timestamp) { + return null; + } + const ms = Date.parse(timestamp); + return Number.isFinite(ms) ? ms : null; +} + function tokenize(segment: string): string[] { return segment .split(/\s+/) diff --git a/desktop/src/features/chats/lib/chatWorkLinks.test.mjs b/desktop/src/features/chats/lib/chatWorkLinks.test.mjs new file mode 100644 index 0000000000..1645cda60b --- /dev/null +++ b/desktop/src/features/chats/lib/chatWorkLinks.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + collectUnambiguousPullRequestSources, + latestUnambiguousPullRequestHref, + latestUnambiguousPullRequestSource, +} from "./chatWorkLinks.ts"; + +test("latest unambiguous PR link wins", () => { + assert.equal( + latestUnambiguousPullRequestHref([ + { content: "Opened https://github.com/block/buzz/pull/1511" }, + { content: "Updated https://github.com/block/buzz/pull/1515" }, + ]), + "https://github.com/block/buzz/pull/1515", + ); +}); + +test("latest unambiguous PR source includes the message timestamp", () => { + const messages = [ + { + content: "Opened https://github.com/block/buzz/pull/1511", + created_at: 100, + }, + { + content: "Updated https://github.com/block/buzz/pull/1515", + created_at: 200, + }, + ]; + + assert.deepEqual(latestUnambiguousPullRequestSource(messages), { + href: "https://github.com/block/buzz/pull/1515", + timestampMs: 200_000, + }); + assert.deepEqual(collectUnambiguousPullRequestSources(messages), [ + { + href: "https://github.com/block/buzz/pull/1511", + timestampMs: 100_000, + }, + { + href: "https://github.com/block/buzz/pull/1515", + timestampMs: 200_000, + }, + ]); +}); + +test("messages mentioning several PRs are skipped as ambiguous", () => { + assert.equal( + latestUnambiguousPullRequestHref([ + { content: "Opened https://github.com/block/buzz/pull/1515" }, + { + content: + "Dictation https://github.com/block/buzz/pull/1511 and spellcheck https://github.com/block/buzz/pull/1515 are both open.", + }, + ]), + "https://github.com/block/buzz/pull/1515", + ); +}); + +test("repeated mentions of the same PR in one message count as one PR", () => { + assert.equal( + latestUnambiguousPullRequestHref([ + { + content: + "PR https://github.com/block/buzz/pull/1515 mirrors [the same PR](https://github.com/block/buzz/pull/1515).", + }, + ]), + "https://github.com/block/buzz/pull/1515", + ); +}); + +test("returns null when no unambiguous PR exists", () => { + assert.equal( + latestUnambiguousPullRequestHref([ + { + content: + "Dictation https://github.com/block/buzz/pull/1511 and spellcheck https://github.com/block/buzz/pull/1515.", + }, + ]), + null, + ); +}); diff --git a/desktop/src/features/chats/lib/chatWorkLinks.ts b/desktop/src/features/chats/lib/chatWorkLinks.ts new file mode 100644 index 0000000000..91b9f3343c --- /dev/null +++ b/desktop/src/features/chats/lib/chatWorkLinks.ts @@ -0,0 +1,61 @@ +import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview"; + +export type ChatPullRequestSource = { + href: string; + timestampMs: number | null; +}; + +/** + * Return the latest chat message that points to exactly one GitHub PR. + * Status updates often mention several PRs; choosing the first/last link from + * that kind of mixed message makes one chat's work panel drift to another PR. + */ +export function latestUnambiguousPullRequestHref( + messages: readonly { content: string; created_at?: number }[], +): string | null { + return latestUnambiguousPullRequestSource(messages)?.href ?? null; +} + +export function latestUnambiguousPullRequestSource( + messages: readonly { content: string; created_at?: number }[], +): ChatPullRequestSource | null { + for (let index = messages.length - 1; index >= 0; index--) { + const source = pullRequestSourceFromMessage(messages[index]); + if (source) { + return source; + } + } + return null; +} + +export function collectUnambiguousPullRequestSources( + messages: readonly { content: string; created_at?: number }[], +): ChatPullRequestSource[] { + return messages.flatMap((message) => { + const source = pullRequestSourceFromMessage(message); + return source ? [source] : []; + }); +} + +function pullRequestSourceFromMessage(message: { + content: string; + created_at?: number; +}): ChatPullRequestSource | null { + const hrefs = [ + ...new Set( + extractSupportedLinkPreviews(message.content) + .filter((candidate) => candidate.kind === "github-pull-request") + .map((preview) => preview.href), + ), + ]; + if (hrefs.length !== 1) { + return null; + } + return { + href: hrefs[0], + timestampMs: + typeof message.created_at === "number" + ? message.created_at * 1_000 + : null, + }; +} diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index 858890671b..3f5066c1ee 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -1,5 +1,12 @@ import * as React from "react"; -import { Bot, FolderGit2, MessageCircle, Power, Wand2 } from "lucide-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"; @@ -209,9 +216,13 @@ export function ChatAutomationRow({ 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 ( @@ -233,6 +244,26 @@ export function ChatContextRow({ event }: { event: RelayEvent }) { ); } + if (isWorkContext) { + return ( + + +
+ + + + + Work context + +
+ +
+
+
+
+ ); + } + return ( diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index 746bb415fe..c9c2f770b6 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -12,7 +12,10 @@ import { useAgentsTranscript, } from "@/features/agents/ui/useObserverEvents"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; -import { useUpdateChatMetadataMutation } from "@/features/chats/hooks"; +import { + useSendChatContextMessageMutation, + useUpdateChatMetadataMutation, +} from "@/features/chats/hooks"; import { buildChatActivityPlacement, shouldHidePersistedAgentMessage, @@ -20,6 +23,7 @@ import { import { chatProjectForMetadata } from "@/features/chats/lib/chatProjects"; import { buildChatCanvasContent, + deriveBranchTitle, buildProjectSetupContext, type ChatProject, deriveChatTitle, @@ -30,11 +34,24 @@ import { ChatActivityTranscript } from "@/features/chats/ui/ChatActivityTranscri import { CHAT_AUTOMATION_TAG, chatAutomationTag, + clearChatPinnedPr, } from "@/features/chats/lib/chatWorkAutomation"; import { - deriveBranchFromAgentMessages, - deriveChatWorkBranch, + 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"; @@ -79,6 +96,53 @@ function eventHasTag(event: RelayEvent, name: string, value?: string) { ); } +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; @@ -126,6 +190,7 @@ export function ChatDetail({ }: 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(); @@ -179,12 +244,51 @@ export function ChatDetail({ // 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 workBranch = React.useMemo( - () => - deriveChatWorkBranch(scopedTranscript) ?? - deriveBranchFromAgentMessages(messages, defaultAgent?.pubkey), - [defaultAgent?.pubkey, messages, scopedTranscript], + 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. @@ -208,6 +312,115 @@ export function ChatDetail({ () => 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 = @@ -259,6 +472,13 @@ export function ChatDetail({ queryKey: ["channel-canvas", chat.id], }); + if (leadingContent) { + await sendChatContextAsync({ + channelId: chat.id, + content: leadingContent, + }); + } + if (nextProject) { onProjectCreated({ ...nextProject, @@ -285,39 +505,64 @@ export function ChatDetail({ 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) && - !shouldHidePersistedAgentMessage({ - event: message, - hiddenAgentMessageIds: chatActivity.hiddenAgentMessageIds, - }) - ); - }), - [chatActivity.hiddenAgentMessageIds, messages], + 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; @@ -349,9 +594,9 @@ export function ChatDetail({ }, [defaultAgent?.pubkey, identityPubkey, messages]); // Auto-title: upgrade a still-default title (the first message, verbatim) - // to a succinct subject line. Prefers the agent-generated `chat_title` - // observer frame — the harness titles the conversation with a real model — - // and falls back to the local heuristic once the conversation develops. + // 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); @@ -381,19 +626,34 @@ export function ChatDetail({ 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); + metadata.title === deriveConversationTitle(firstOwnMessage.content) || + branchAutoTitles.has(metadata.title); if (!isAutoTitle) { return; } let nextTitle: string | null = null; let isHeuristicTitle = false; - if (agentChatTitle && agentChatTitle.trim().length > 0) { + 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 @@ -460,6 +720,8 @@ export function ChatDetail({ }); }, [ agentChatTitle, + boundBranch, + branchAnchors, chat.id, defaultAgent?.pubkey, identityPubkey, @@ -484,21 +746,7 @@ export function ChatDetail({ latestVisibleMessageIsOwn && latestMessageActivityBlocks.length === 0 && !isChatTurnActive; - const activationDelayKey = - latestVisibleMessage != null - ? `${latestVisibleMessage.id}:${scopedTranscript.length}` - : ""; - const [showDelayedActivationCard, setShowDelayedActivationCard] = - React.useState(false); - // A just-activated agent needs time to spawn, connect, replay the pending - // message, and start its turn. Without this grace window the card re-shows - // ~1s after activation and reads as "activation didn't work". - const AGENT_ACTIVATION_GRACE_MS = 20_000; - // "Activated" isn't done until the agent responds: the card's button holds - // its loading state from click until this chat has a live turn (the card - // unmounts then) or the give-up window expires — the start API returning - // only means the process launched, not that it's connected and replaying - // the pending message. + // 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(() => { @@ -523,11 +771,13 @@ export function ChatDetail({ [defaultAgent, onActivateAgent, onSend], ); + const defaultAgentActive = + defaultAgent != null && isManagedAgentActive(defaultAgent); React.useEffect(() => { if (!isActivationPending) { return; } - if (isChatTurnActive) { + if (isChatTurnActive || defaultAgentActive) { setIsActivationPending(false); return; } @@ -535,40 +785,12 @@ export function ChatDetail({ setIsActivationPending(false); }, ACTIVATION_PENDING_MS); return () => window.clearTimeout(timeout); - }, [isActivationPending, isChatTurnActive]); - const [activationGraceUntil, setActivationGraceUntil] = React.useState(0); - const wasActivatingRef = React.useRef(false); - React.useEffect(() => { - if (wasActivatingRef.current && !isActivatingAgent) { - setActivationGraceUntil(Date.now() + AGENT_ACTIVATION_GRACE_MS); - } - wasActivatingRef.current = isActivatingAgent; - }, [isActivatingAgent]); - React.useEffect(() => { - if (!activationDelayKey || !latestOwnMessageNeedsAgent || !hasObserver) { - setShowDelayedActivationCard(false); - return; - } - - const delayMs = Math.max(1_200, activationGraceUntil - Date.now()); - const timeout = window.setTimeout(() => { - setShowDelayedActivationCard(true); - }, delayMs); - return () => window.clearTimeout(timeout); - }, [ - activationDelayKey, - activationGraceUntil, - hasObserver, - latestOwnMessageNeedsAgent, - ]); - // A stopped default agent always shows the card — "is it running, is it - // silent?" must never be ambiguous. The delayed path still covers a - // running-but-unresponsive agent. - const defaultAgentInactive = - defaultAgent != null && !isManagedAgentActive(defaultAgent); + }, [defaultAgentActive, isActivationPending, isChatTurnActive]); + // A stopped default agent always shows the card. + const defaultAgentInactive = defaultAgent != null && !defaultAgentActive; const shouldShowAgentActivationCard = (defaultAgentInactive && latestVisibleMessage != null) || - (latestOwnMessageNeedsAgent && (!hasObserver || showDelayedActivationCard)); + (!defaultAgentActive && latestOwnMessageNeedsAgent && !hasObserver); const forceScrollSignature = latestVisibleMessageIsOwn ? latestVisibleMessage.id : null; @@ -762,12 +984,12 @@ export function ChatDetail({ diff --git a/desktop/src/features/chats/ui/ChatHeaderActions.tsx b/desktop/src/features/chats/ui/ChatHeaderActions.tsx index 9a36dedb7f..34a769c194 100644 --- a/desktop/src/features/chats/ui/ChatHeaderActions.tsx +++ b/desktop/src/features/chats/ui/ChatHeaderActions.tsx @@ -12,7 +12,7 @@ import { toast } from "sonner"; import { useManagedAgentsQuery } from "@/features/agents/hooks"; import { ChannelCanvas } from "@/features/channels/ui/ChannelCanvas"; import { useUpdateChatMetadataMutation } from "@/features/chats/hooks"; -import { buildChatLink } from "@/features/chats/lib/chatLink"; +import { buildDualOpenChatLink } from "@/features/chats/lib/chatLink"; import { cleanAssistantMessageText, isHumanFacingAssistantText, @@ -24,7 +24,10 @@ import type { ManagedAgent, RelayEvent, } from "@/shared/api/types"; -import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; +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"; @@ -292,13 +295,18 @@ function ChatShareMenu({ metadata, }: ChatHeaderActionsProps) { const [isSharingSummary, setIsSharingSummary] = React.useState(false); + const fallbackMessageId = React.useMemo( + () => latestChatLinkAnchorMessageId(messages), + [messages], + ); const chatLink = React.useMemo( () => - buildChatLink({ + buildDualOpenChatLink({ chatId: chat.id, + fallbackMessageId, title: metadata?.title?.trim() || chat.name, }), - [chat.id, chat.name, metadata?.title], + [chat.id, chat.name, fallbackMessageId, metadata?.title], ); const canShareSummary = Boolean(metadata?.sourceChannelId); @@ -443,6 +451,21 @@ function summarizeChat( 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), diff --git a/desktop/src/features/chats/ui/ChatWorkPanel.tsx b/desktop/src/features/chats/ui/ChatWorkPanel.tsx index 26f33049dd..7939d8c771 100644 --- a/desktop/src/features/chats/ui/ChatWorkPanel.tsx +++ b/desktop/src/features/chats/ui/ChatWorkPanel.tsx @@ -13,12 +13,17 @@ import { 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, @@ -51,6 +56,11 @@ const lastShownPrByChat = new Map(); // 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. @@ -94,40 +104,62 @@ export function ChatWorkPanel({ // "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 [pinned, setPinned] = React.useState(() => - readChatPinnedPr(chatId), - ); + const [pinnedState, setPinnedState] = React.useState(() => ({ + chatId, + pinned: readChatPinnedPr(chatId), + })); + const pinned = + pinnedState.chatId === chatId + ? pinnedState.pinned + : readChatPinnedPr(chatId); React.useEffect(() => { - setPinned(readChatPinnedPr(chatId)); + 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 && pinned === null ? projectPath : null, + monitorActive && !prHref && !manualHref && !autoPinnedHref && !isUnpinned + ? projectPath + : null, branch, ); - const manualHref = pinned?.manual && pinned.href ? pinned.href : null; const effectiveHref = manualHref ?? prHref ?? - (isUnpinned - ? null - : ((pinned?.href || null) ?? discoveredPrQuery.data ?? null)); + autoPinnedHref ?? + (isUnpinned ? null : (discoveredPrQuery.data ?? null)); React.useEffect(() => { - // Remember what the chat resolved to — but never downgrade a manual pin. + // 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 (effectiveHref && effectiveHref !== current?.href) { - writeChatPinnedPr(chatId, effectiveHref); - setPinned({ href: effectiveHref, manual: false }); + if (prHref && prHref !== current?.href) { + writeChatPinnedPr(chatId, prHref); + updateChatWorkBinding( + chatId, + { prHref, prDetached: false }, + { replacePr: false }, + ); + setPinnedState({ chatId, pinned: { href: prHref, manual: false } }); } - }, [chatId, effectiveHref]); + }, [chatId, prHref]); const handleUnlinkPr = React.useCallback(() => { writeChatPinnedPr(chatId, CHAT_PR_UNPINNED, true); - setPinned({ href: CHAT_PR_UNPINNED, manual: 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(""); @@ -138,7 +170,12 @@ export function ChatWorkPanel({ return; } writeChatPinnedPr(chatId, trimmed, true); - setPinned({ href: trimmed, manual: true }); + updateChatWorkBinding( + chatId, + { prHref: trimmed, prDetached: false }, + { replacePr: true }, + ); + setPinnedState({ chatId, pinned: { href: trimmed, manual: true } }); setIsPinEditorOpen(false); setPinInput(""); }, [chatId, pinInput]); @@ -151,6 +188,24 @@ export function ChatWorkPanel({ 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); diff --git a/desktop/src/features/chats/ui/ChatsScreen.tsx b/desktop/src/features/chats/ui/ChatsScreen.tsx index 125d2dce85..a71a5b3b9e 100644 --- a/desktop/src/features/chats/ui/ChatsScreen.tsx +++ b/desktop/src/features/chats/ui/ChatsScreen.tsx @@ -10,6 +10,7 @@ import { useManagedAgentsQuery, useStartManagedAgentMutation, } from "@/features/agents/hooks"; +import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks"; import { useArchiveChatMutation, @@ -25,6 +26,7 @@ import { toggleStoredChatPin, useStoredChatPins, } from "@/features/chats/lib/chatPinStorage"; +import { latestUnambiguousPullRequestHref } from "@/features/chats/lib/chatWorkLinks"; import { mergeChatProjects, upsertStoredChatProject, @@ -49,7 +51,6 @@ 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 { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { getMentionTagPubkey } from "@/shared/lib/resolveMentionNames"; @@ -326,16 +327,10 @@ export function ChatsScreen({ // 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(() => { - for (let index = messages.length - 1; index >= 0; index--) { - const preview = extractSupportedLinkPreviews( - messages[index].content, - ).find((candidate) => candidate.kind === "github-pull-request"); - if (preview) { - return preview.href; - } - } - return null; + 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 @@ -525,10 +520,7 @@ export function ChatsScreen({ try { if (defaultAgent) { await addBotToChat(selectedChat.id, defaultAgent.pubkey); - if ( - defaultAgent.status !== "running" && - defaultAgent.status !== "deployed" - ) { + 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. @@ -651,17 +643,13 @@ export function ChatsScreen({ } aria-pressed={isWorkPanelOpen} data-testid="toggle-work-panel" + className={cn(isWorkPanelOpen && "bg-accent")} onClick={() => handleWorkPanelPreference(!isWorkPanelOpen)} size="icon" type="button" variant="ghost" > - + } /> diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 0dba1fed9a..d4894b3621 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -15,6 +15,7 @@ import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; 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"; @@ -1944,25 +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 goChat(link.chatId); + void resolveChatOpenDestination(link.chatId).then((destination) => { + if (destination.kind === "chat") { + void goChat(destination.chatId); + } else { + void goChannel(destination.channelId); + } + }); }, - [goChat], + [goChannel, goChat], ); const linkPreviews = React.useMemo( () => (interactive ? extractSupportedLinkPreviews(content) : []), diff --git a/desktop/src/shared/useMessageDeepLinks.ts b/desktop/src/shared/useMessageDeepLinks.ts index b81ca090ff..19cc23c5e8 100644 --- a/desktop/src/shared/useMessageDeepLinks.ts +++ b/desktop/src/shared/useMessageDeepLinks.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { resolveChatOpenDestination } from "@/features/chats/lib/chatOpenDestination"; import { listenForChatDeepLinks, listenForMessageDeepLinks, @@ -18,23 +19,40 @@ import { * first time the listener mounts. Message routing matches the in-app * buzz:// handler in `markdown.tsx`: use `goChannel` with `messageId` and * let the channel route's existing scroll-into-view + getEventById backfill - * resolve the target. Chat routing uses the chats route directly. + * resolve the target. Chat routing prefers the chats route, then falls back + * to the underlying private channel when chat metadata is unavailable. */ export function useMessageDeepLinks() { const { goChannel, goChat } = useAppNavigation(); React.useEffect(() => { let cancelled = false; + const openChatOrChannel = async (channelId: string) => { + const destination = await resolveChatOpenDestination(channelId); + if (cancelled) return; + if (destination.kind === "chat") { + void goChat(destination.chatId); + } else { + void goChannel(destination.channelId); + } + }; const unlistenMessagePromise = listenForMessageDeepLinks((payload) => { if (cancelled) return; - void goChannel(payload.channelId, { - messageId: payload.messageId, - threadRootId: payload.threadRootId, + void resolveChatOpenDestination(payload.channelId).then((destination) => { + if (cancelled) return; + if (destination.kind === "chat") { + void goChat(destination.chatId); + return; + } + void goChannel(destination.channelId, { + messageId: payload.messageId, + threadRootId: payload.threadRootId, + }); }); }); const unlistenChatPromise = listenForChatDeepLinks((payload) => { if (cancelled) return; - void goChat(payload.chatId); + void openChatOrChannel(payload.chatId); }); return () => { cancelled = true;