From 5678399aa79f9229c02d12ae1a190520f357b2f6 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 25 Jun 2026 14:18:26 +0100 Subject: [PATCH 1/8] Add continued agent conversations --- crates/buzz-acp/src/lib.rs | 28 +- crates/buzz-acp/src/pool.rs | 292 +------ crates/buzz-acp/src/queue.rs | 24 +- crates/buzz-acp/src/relay.rs | 20 +- crates/buzz-core/src/kind.rs | 3 + crates/buzz-relay/src/handlers/ingest.rs | 8 +- desktop/scripts/check-file-sizes.mjs | 25 +- .../src-tauri/src/commands/agent_models.rs | 2 + desktop/src-tauri/src/commands/agents.rs | 15 + .../src-tauri/src/managed_agents/discovery.rs | 40 +- .../src-tauri/src/managed_agents/restore.rs | 50 +- .../src-tauri/src/managed_agents/runtime.rs | 67 +- .../src/managed_agents/runtime/tests.rs | 33 +- desktop/src/app/AppShell.tsx | 242 +++++- desktop/src/app/AppShellContext.tsx | 18 + .../features/agents/agentConversationRecap.ts | 202 +++++ .../agents/agentConversationTitles.ts | 582 +++++++++++++ .../agents/agentConversations.test.mjs | 409 +++++++++ .../src/features/agents/agentConversations.ts | 680 +++++++++++++++ .../ui/AgentConversationScreen.helpers.ts | 276 ++++++ .../agents/ui/AgentConversationScreen.tsx | 791 ++++++++++++++++++ .../channels/ui/ChannelPane.helpers.test.mjs | 155 ++++ .../channels/ui/ChannelPane.helpers.ts | 120 +++ .../src/features/channels/ui/ChannelPane.tsx | 155 +++- .../features/channels/ui/ChannelScreen.tsx | 43 + .../channels/useChannelPaneHandlers.ts | 25 +- desktop/src/features/chat/ui/ChatHeader.tsx | 88 +- .../messages/lib/threadPanel.test.mjs | 33 +- .../src/features/messages/lib/threadPanel.ts | 107 +-- .../ui/AgentConversationMarkerRow.tsx | 351 ++++++++ .../features/messages/ui/MessageActionBar.tsx | 32 +- .../features/messages/ui/MessageComposer.tsx | 12 +- .../src/features/messages/ui/MessageRow.tsx | 110 ++- .../messages/ui/MessageThreadPanel.tsx | 593 +++++-------- .../messages/ui/MessageThreadSummaryRow.tsx | 4 +- .../features/messages/ui/MessageTimeline.tsx | 38 +- .../messages/ui/TimelineMessageList.tsx | 50 ++ .../messages/ui/useComposerHeightPadding.ts | 9 +- .../search/ui/SearchPromptPlaceholder.tsx | 159 +--- .../src/features/sidebar/ui/AppSidebar.tsx | 125 ++- .../sidebar/ui/CustomChannelSection.tsx | 259 +++--- .../ui/SidebarAgentConversationChildren.tsx | 112 +++ .../features/sidebar/ui/SidebarSection.tsx | 156 ++-- desktop/src/shared/api/relayChannelFilters.ts | 9 +- desktop/src/shared/constants/kinds.ts | 12 + desktop/src/shared/styles/globals.css | 43 +- desktop/src/shared/ui/AnimatedTextSwap.tsx | 191 +++++ desktop/src/shared/ui/Shimmer.tsx | 5 +- 48 files changed, 5551 insertions(+), 1252 deletions(-) create mode 100644 desktop/src/features/agents/agentConversationRecap.ts create mode 100644 desktop/src/features/agents/agentConversationTitles.ts create mode 100644 desktop/src/features/agents/agentConversations.test.mjs create mode 100644 desktop/src/features/agents/agentConversations.ts create mode 100644 desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts create mode 100644 desktop/src/features/agents/ui/AgentConversationScreen.tsx create mode 100644 desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs create mode 100644 desktop/src/features/messages/ui/AgentConversationMarkerRow.tsx create mode 100644 desktop/src/features/sidebar/ui/SidebarAgentConversationChildren.tsx create mode 100644 desktop/src/shared/ui/AnimatedTextSwap.tsx diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index e4b68dffe..5bf695371 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1605,21 +1605,6 @@ async fn tokio_main() -> Result<()> { // their sessions stripped when they return to the pool. removed_channels.insert(ch); typing_channels.remove(&ch); - // Best-effort: clean up 👀 on drained events. - // Note: the relay revokes membership before - // emitting the notification, so this DELETE may - // 403 on non-open channels. Stale 👀 in that - // case is a known limitation — fix belongs in - // the relay (clean up bot reactions on removal). - if !drained_ids.is_empty() { - let rc = ctx.rest_client.clone(); - let ids = drained_ids.clone(); - tokio::spawn(async move { - for eid in &ids { - pool::reaction_remove(&rc, eid, "👀").await; - } - }); - } if !drained_ids.is_empty() || invalidated > 0 { tracing::info!( channel_id = %ch, @@ -1783,24 +1768,13 @@ async fn tokio_main() -> Result<()> { // Capture author pubkey before queue.push() moves // buzz_event.event (needed for mode gate below). let author_hex = buzz_event.event.pubkey.to_hex(); - let event_id_hex = buzz_event.event.id.to_hex(); let accepted = queue.push(QueuedEvent { channel_id: buzz_event.channel_id, event: buzz_event.event, received_at: std::time::Instant::now(), prompt_tag, }); - // 👀 — immediate "seen" reaction, only if the event - // was actually queued (not dropped by DedupMode::Drop). - // Fire-and-forget: on rare fast-failure paths the - // guard's cleanup may race with this add, leaving a - // cosmetic stale 👀. Acceptable — see ReactionGuard docs. - if accepted { - let rc = ctx.rest_client.clone(); - tokio::spawn(async move { - pool::reaction_add(&rc, &event_id_hex, "👀").await; - }); - } + // ── Multiple-event-handling mode gate ───────────── // Event is already queued. If mode requires it AND // the channel has an in-flight task, fire cancel. if accepted && queue.is_channel_in_flight(buzz_event.channel_id) { diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 523d5cf74..5a6b75c14 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -775,15 +775,7 @@ pub async fn run_prompt_task( }), ); - // Collects event IDs up front. On drop (any exit path — normal, early - // return, or panic), spawns best-effort cleanup of both 👀 and 💬. - // See `ReactionGuard` docs for ordering guarantees and known edge cases. - let reaction_ids: Vec = batch - .as_ref() - .map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect()) - .unwrap_or_default(); - let _reaction_guard = ReactionGuard::new(ctx.rest_client.clone(), reaction_ids.clone()); - + // ── Turn completion guard ───────────────────────────────────────────── // Emits `turn_completed` on any exit path. Captures observer handle and // metadata now, before the agent is moved into PromptResult. let _turn_guard = TurnCompletionGuard::new( @@ -1134,16 +1126,7 @@ pub async fn run_prompt_task( return; }; - // 💬 — fire-and-forget so the prompt fires immediately. - // The guard's cleanup (spawned on drop) removes 💬 after the turn completes. - // A brief race where 💬 appears slightly after the agent starts is acceptable. - if !reaction_ids.is_empty() { - let rest = ctx.rest_client.clone(); - let ids = reaction_ids.clone(); - tokio::spawn(async move { - react_working(&rest, &ids).await; - }); - } + // ── Send the actual prompt ──────────────────────────────────────────── // Slash-command pass-through sends the bare command as the first text // block (so connector detection fires), then each prompt section as its @@ -1452,7 +1435,6 @@ pub async fn run_prompt_task( }); } } - // _reaction_guard drops here → spawns clear_reactions for all exit paths. } /// Retry wrapper for context fetches: one retry with `CONTEXT_FETCH_RETRY_DELAY` @@ -2060,67 +2042,7 @@ fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) { } } -// -// Two-phase lifecycle visible to users: -// 👀 "seen" — event was queued and an agent will handle it -// 💬 "working" — agent is actively prompting -// -// 💬 is awaited inline in `run_prompt_task` before the prompt fires, so -// add-before-remove ordering is structural. 👀 is fire-and-forget from -// `main.rs` at queue-push time for immediate responsiveness; on rare -// fast-failure paths the guard's cleanup may race with the 👀 add, -// leaving a cosmetic stale 👀 (see `ReactionGuard` docs). -// -// Cleanup is fire-and-forget via `ReactionGuard` (spawned on drop). -// Failures are debug-logged and ignored — reactions are cosmetic. - -/// Drop guard that spawns reaction cleanup on any exit path. -/// -/// Created at the top of `run_prompt_task`. On drop — normal return, early -/// return, or panic — spawns fire-and-forget removal of both 👀 and 💬. -/// -/// ## Ordering -/// -/// 💬 (`react_working`) is fire-and-forget (spawned before the prompt fires). -/// A brief race where 💬 appears slightly after the agent starts is acceptable. -/// -/// 👀 (`react_seen`) is fire-and-forget from `main.rs` at queue-push time. -/// On rare fast-failure paths (e.g., `session_new` error on an idle agent), -/// the cleanup spawn may race with the 👀 add, leaving a stale 👀. This is -/// accepted as a cosmetic edge case — the message will be retried and the -/// stale 👀 is harmless. -struct ReactionGuard { - rest: Option, - ids: Vec, -} - -impl ReactionGuard { - fn new(rest: crate::relay::RestClient, ids: Vec) -> Self { - Self { - rest: if ids.is_empty() { None } else { Some(rest) }, - ids, - } - } -} - -impl Drop for ReactionGuard { - fn drop(&mut self) { - // Guard against drop outside a tokio runtime (e.g., in unit tests or - // during process teardown before the runtime is fully initialized). - // `run_prompt_task` is always spawned via `JoinSet::spawn`, so a - // runtime handle is normally available; `try_current` is the safe - // fallback for the rare cases it isn't. - if let Some(rest) = self.rest.take() { - let ids = std::mem::take(&mut self.ids); - if let Ok(handle) = tokio::runtime::Handle::try_current() { - handle.spawn(clear_reactions(rest, ids)); - } - // If no runtime is available, reactions are left as-is — they are - // cosmetic indicators and the stale state is harmless. - } - } -} - +// ── Turn liveness emission ─────────────────────────────────────────────────── // Periodically emits a `turn_liveness` observer event while a turn is in-flight, // so the desktop can prune turns whose host died without unwinding (kill -9 / // crash) far sooner than the no-activity backstop. Runs as a non-resolving @@ -2205,178 +2127,7 @@ impl Drop for TurnCompletionGuard { } } -const REACTION_SEEN: &str = "👀"; -const REACTION_WORKING: &str = "💬"; - -/// Best-effort timeout for a single reaction REST call. -const REACTION_TIMEOUT: Duration = Duration::from_millis(500); - -/// Percent-encode a string for use in a URL path segment (used in tests only). -#[cfg(test)] -fn pct_encode(s: &str) -> String { - let mut out = String::with_capacity(s.len() * 3); - for byte in s.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(byte as char); - } - _ => { - use std::fmt::Write; - let _ = write!(out, "%{byte:02X}"); - } - } - } - out -} - -/// Best-effort: add a reaction via a signed Nostr kind-7 event (NIP-25). -/// -/// Builds a reaction event with `buzz_sdk::build_reaction`, signs it with -/// the keys already stored in `RestClient`, and submits via `POST /events`. -/// Returns immediately on timeout or any error — reactions are cosmetic. -pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str, emoji: &str) { - let target_id = match nostr::EventId::from_hex(event_id) { - Ok(id) => id, - Err(e) => { - tracing::debug!(event_id, emoji, "reaction add: invalid event ID: {e}"); - return; - } - }; - let builder = match buzz_sdk::build_reaction(target_id, emoji) { - Ok(b) => b, - Err(e) => { - tracing::warn!(event_id, emoji, "reaction add: build failed: {e}"); - return; - } - }; - let event = match builder.sign_with_keys(&rest.keys) { - Ok(e) => e, - Err(e) => { - tracing::warn!(event_id, emoji, "reaction add: sign failed: {e}"); - return; - } - }; - match tokio::time::timeout(REACTION_TIMEOUT, rest.submit_event(&event)).await { - Ok(Ok(_)) => {} - Ok(Err(e)) => tracing::debug!(event_id, emoji, "reaction add failed: {e}"), - Err(_) => tracing::debug!(event_id, emoji, "reaction add timed out"), - } -} - -/// Best-effort: remove a reaction via a signed kind:5 (NIP-09) deletion event. -/// -/// Queries kind:7 reactions by our pubkey targeting the event, finds the matching -/// emoji, then submits a signed kind:5 deletion via `POST /events`. -/// Returns immediately on timeout or any error — reactions are cosmetic. -pub(crate) async fn reaction_remove(rest: &crate::relay::RestClient, event_id: &str, emoji: &str) { - use nostr::{Alphabet, SingleLetterTag}; - - // Step 1: query our kind:7 reactions targeting this event. - let my_pubkey = rest.keys.public_key(); - let e_tag = SingleLetterTag::lowercase(Alphabet::E); - let filter = nostr::Filter::new() - .kind(nostr::Kind::Reaction) - .author(my_pubkey) - .custom_tags(e_tag, [event_id]); - - let resp = match tokio::time::timeout(Duration::from_millis(1_000), rest.query(&[filter])).await - { - Ok(Ok(v)) => v, - Ok(Err(e)) => { - tracing::debug!(event_id, emoji, "reaction remove: query failed: {e}"); - return; - } - Err(_) => { - tracing::debug!(event_id, emoji, "reaction remove: query timed out"); - return; - } - }; - - // Find our reaction event with matching emoji content. - let reid = resp.as_array().and_then(|events| { - events.iter().find_map(|ev| { - let content = ev.get("content")?.as_str()?; - if content != emoji { - return None; - } - ev.get("id")?.as_str().map(|s| s.to_string()) - }) - }); - - let reid = match reid { - Some(id) => id, - None => { - tracing::debug!(event_id, emoji, "reaction remove: no reaction event found"); - return; - } - }; - - // Step 2: build and submit a signed kind:5 deletion for the reaction event. - let target_id = match nostr::EventId::from_hex(&reid) { - Ok(id) => id, - Err(e) => { - tracing::debug!( - event_id, - emoji, - "reaction remove: invalid reaction event ID: {e}" - ); - return; - } - }; - let builder = match buzz_sdk::build_remove_reaction(target_id) { - Ok(b) => b, - Err(e) => { - tracing::warn!(event_id, emoji, "reaction remove: build failed: {e}"); - return; - } - }; - let event = match builder.sign_with_keys(&rest.keys) { - Ok(e) => e, - Err(e) => { - tracing::warn!(event_id, emoji, "reaction remove: sign failed: {e}"); - return; - } - }; - match tokio::time::timeout(Duration::from_millis(1_000), rest.submit_event(&event)).await { - Ok(Ok(_)) => {} - Ok(Err(e)) => tracing::debug!(event_id, emoji, "reaction remove failed: {e}"), - Err(_) => tracing::debug!(event_id, emoji, "reaction remove timed out"), - } -} - -/// Maximum concurrent reaction HTTP requests per fan-out call. -/// Prevents unbounded parallelism when a large batch of events arrives. -const REACTION_CONCURRENCY: usize = 10; - -/// Add 💬 to all events, capped at `REACTION_CONCURRENCY` concurrent requests. -/// Awaited inline before the prompt fires. -async fn react_working(rest: &crate::relay::RestClient, event_ids: &[String]) { - for chunk in event_ids.chunks(REACTION_CONCURRENCY) { - futures_util::future::join_all( - chunk - .iter() - .map(|eid| reaction_add(rest, eid, REACTION_WORKING)), - ) - .await; - } -} - -/// Fire-and-forget: remove both 👀 and 💬 from all events. Spawned on turn complete. -/// Capped at `REACTION_CONCURRENCY` concurrent requests per chunk to avoid -/// unbounded HTTP fan-out on large batches. -async fn clear_reactions(rest: crate::relay::RestClient, event_ids: Vec) { - // Each event needs two removals (👀 and 💬); pair them and chunk by - // REACTION_CONCURRENCY pairs so the total concurrent requests stay bounded. - for chunk in event_ids.chunks(REACTION_CONCURRENCY) { - futures_util::future::join_all(chunk.iter().flat_map(|eid| { - [ - reaction_remove(&rest, eid, REACTION_SEEN), - reaction_remove(&rest, eid, REACTION_WORKING), - ] - })) - .await; - } -} +// ─── Unit Tests ────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { @@ -2844,40 +2595,7 @@ mod tests { assert_eq!(msg.pubkey, "unknown"); } - #[test] - fn test_pct_encode_hex_passthrough() { - let hex = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; - assert_eq!(pct_encode(hex), hex); - } - - #[test] - fn test_pct_encode_emoji() { - // 👀 = U+1F440 = F0 9F 91 80 in UTF-8 - assert_eq!(pct_encode("👀"), "%F0%9F%91%80"); - } - - #[test] - fn test_pct_encode_emoji_speech_balloon() { - // 💬 = U+1F4AC = F0 9F 92 AC in UTF-8 - assert_eq!(pct_encode("💬"), "%F0%9F%92%AC"); - } - - #[test] - fn test_pct_encode_empty() { - assert_eq!(pct_encode(""), ""); - } - - #[test] - fn test_pct_encode_unreserved_passthrough() { - assert_eq!(pct_encode("AZaz09-_.~"), "AZaz09-_.~"); - } - - #[test] - fn test_pct_encode_reserved_chars() { - assert_eq!(pct_encode("/"), "%2F"); - assert_eq!(pct_encode("+"), "%2B"); - assert_eq!(pct_encode(" "), "%20"); - } + // ── SessionState tests ─────────────────────────────────────────────── fn make_state() -> (SessionState, Uuid, Uuid) { let ch_a = Uuid::new_v4(); diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index a5da30d43..f7df88cc4 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -499,7 +499,7 @@ impl EventQueue { /// Also clears any `retry_after` throttle for the channel. /// /// Returns the event IDs of dropped events so the caller can clean up - /// any reactions (👀) that were added at queue-push time. + /// channel-scoped side effects if needed. pub fn drain_channel(&mut self, channel_id: Uuid) -> Vec { let ids = self .queues @@ -872,15 +872,15 @@ fn format_event_block( /// Append a reply instruction when the agent is responding to a thread event. /// -/// Tells the agent to default to `--reply-to ` for ordinary replies -/// while still allowing an explicit human request to post at the channel root or -/// top level. -fn append_reply_instruction(s: &mut String, event_id: &str) { +/// Tells the agent to pass `--reply-to ` on every `buzz +/// messages send` call for ordinary replies, while still allowing an explicit +/// human request to post at the channel root or top level. +fn append_reply_instruction(s: &mut String, thread_root_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. \ - If the human explicitly asks for a channel-root, top-level, \ - or broadcast post, send that message without `--reply-to`. \ + "\nIMPORTANT: For ordinary replies in this turn, use `--reply-to {thread_root_id}` \ + on `buzz messages send` so the conversation stays in the thread without adding \ + another visible nesting level. 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." )); } @@ -1166,6 +1166,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec Result { - let body_bytes = serde_json::to_vec(event) - .map_err(|e| RelayError::Http(format!("event serialize error: {e}")))?; - let resp = self.bridge_post("/events", &body_bytes).await?; - let text = resp - .text() - .await - .map_err(|e| RelayError::Http(e.to_string()))?; - if text.is_empty() { - return Ok(Value::Null); - } - serde_json::from_str(&text).map_err(|e| RelayError::Http(e.to_string())) - } } /// Events the harness cares about. diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 922c64c20..14ce983f5 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -274,6 +274,8 @@ pub const KIND_STREAM_MESSAGE_SCHEDULED: u32 = 40006; pub const KIND_STREAM_REMINDER: u32 = 40007; /// A diff/patch message showing file changes (unified diff format). pub const KIND_STREAM_MESSAGE_DIFF: u32 = 40008; +/// Shared marker that a channel thread has a focused agent conversation view. +pub const KIND_AGENT_CONVERSATION: u32 = 40010; /// Canvas (shared document) for a channel. pub const KIND_CANVAS: u32 = 40100; /// System message for channel state changes (join, leave, rename, etc.). @@ -463,6 +465,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_REMINDER, KIND_STREAM_MESSAGE_DIFF, + KIND_AGENT_CONVERSATION, KIND_CANVAS, KIND_SYSTEM_MESSAGE, KIND_CHANNEL_SUMMARY, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 1d6df717a..a8ac948aa 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -12,9 +12,9 @@ use uuid::Uuid; use buzz_auth::Scope; 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_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, + is_relay_admin_kind, KIND_AGENT_CONVERSATION, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, + 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, @@ -180,6 +180,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), @@ -388,6 +389,7 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_STREAM_MESSAGE_SCHEDULED | KIND_STREAM_REMINDER | KIND_STREAM_MESSAGE_DIFF + | KIND_AGENT_CONVERSATION | KIND_CANVAS | KIND_FORUM_POST | KIND_FORUM_VOTE diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 6f0281e9b..32360504a 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -36,7 +36,9 @@ const overrides = new Map([ // rebase, queued to split with the rest of this list. // persona-refresh-on-spawn: re-snapshot + retain_managed_agent_pending call // in start_local_agent_with_preflight adds ~23 lines. Queued to split. - ["src-tauri/src/commands/agents.rs", 1380], + // continued-agent-conversations: refreshes the owner auth tag before + // starting/restoring agents so staged identities keep working. + ["src-tauri/src/commands/agents.rs", 1388], // Residual repos_dir integration in ensure_nest_at: REPOS is provisioned // outside NEST_DIRS (it may be a symlink), so it needs its own create + // chmod-only-when-real-dir handling plus integration test coverage. The @@ -47,7 +49,9 @@ const overrides = new Map([ // harness-persona-sync: persona-runtime resolution threaded into the spawn // path here. Load-bearing feature growth; queued to split in the resolver // unify refactor followup. - ["src-tauri/src/managed_agents/runtime.rs", 2031], + // continued-agent-conversations: owner-scoped auth tag refresh is threaded + // through the runtime env builder and covered by regression tests. + ["src-tauri/src/managed_agents/runtime.rs", 2084], ["src-tauri/src/managed_agents/personas.rs", 1080], // Phase-2 inbound reconcile + review-fix cycle: reconcile_inbound_persona_event // dispatches 30175/30176/30177 inbound plus kind:5 tombstone consume @@ -69,7 +73,9 @@ const overrides = new Map([ // (the effective_agent_command / divergent / create-time override matrix); // types.rs adds the persona/instance harness fields. Load-bearing, not // generic debt. - ["src-tauri/src/managed_agents/discovery.rs", 1043], + // continued-agent-conversations: effective command fallback tests ensure + // persisted staged agents restart with their stored command. + ["src-tauri/src/managed_agents/discovery.rs", 1063], ["src-tauri/src/managed_agents/types.rs", 1037], // migration_tests.rs carries the harness-sync migration coverage plus the // patch_json_records owner-only writeback regression test (SECURITY.md:90 @@ -87,7 +93,18 @@ const overrides = new Map([ // useDueReminderBadgeCount hook call + sum to wire due-reminder count into // the Inbox nav badge — a small overage from load-bearing badge plumbing, // not generic debt growth. Approved override; still queued to split. - ["src/app/AppShell.tsx", 1010], + // continued-agent-conversations: persisted channel-scoped conversation state + // and route wiring. Queued to split with the rest of AppShell state. + ["src/app/AppShell.tsx", 1060], + // continued-agent-conversations: marker filtering, thread handoff, and + // activity handoff props live at the channel surface for now. + ["src/features/channels/ui/ChannelPane.tsx", 1107], + // continued-agent-conversations: composer notice banner for read-only agent + // conversations. + ["src/features/messages/ui/MessageComposer.tsx", 1010], + // continued-agent-conversations: channel sidebar children and active + // conversation unread suppression. Queued to split with sidebar sections. + ["src/features/sidebar/ui/AppSidebar.tsx", 1081], // PersistBackend enum + marker-on-keyring-success plumbing and its three // fail-closed regression tests (silent identity rotation on keyring outage). // A small overage from load-bearing security plumbing on a file already at diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index d0261714e..08f822dcb 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -57,6 +57,7 @@ pub async fn get_agent_models( record.persona_id.as_deref(), &personas, record.agent_command_override.as_deref(), + Some(&record.agent_command), ); let args = normalize_agent_args(&effective_command, record.agent_args.clone()); @@ -285,6 +286,7 @@ pub async fn update_managed_agent( record.persona_id.as_deref(), &personas, record.agent_command_override.as_deref(), + Some(&record.agent_command), ); let avatar_url = record .avatar_url diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 706045a32..786d1dcdc 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -242,6 +242,11 @@ async fn start_local_agent_with_preflight( ensure_relay_mesh_for_record(app, &record_snapshot, allow_fresh_create_start).await?; + let refreshed_auth_tag = { + let owner_keys = state.keys.lock().map_err(|e| e.to_string())?; + crate::managed_agents::managed_agent_auth_tag_for_owner(&owner_keys, pubkey)? + }; + let _store_guard = state .managed_agents_store_lock .lock() @@ -255,6 +260,14 @@ async fn start_local_agent_with_preflight( if record.backend != BackendKind::Local { return Err(format!("agent {pubkey} is no longer a local agent")); } + if !crate::managed_agents::auth_tag_matches_owner( + record.auth_tag.as_deref(), + &record.pubkey, + Some(owner_hex), + ) { + record.auth_tag = refreshed_auth_tag; + record.updated_at = crate::util::now_iso(); + } // Re-snapshot the persona onto the record at every spawn so the agent always // starts with the current persona config (system_prompt, model, provider, // env_vars). This clears the "out of date" drift badge without requiring a @@ -606,6 +619,7 @@ pub async fn create_managed_agent( requested_persona_id.as_deref(), &personas, agent_command_override.as_deref(), + None, ); let agent_args = normalize_agent_args( &agent_command, @@ -963,6 +977,7 @@ pub async fn start_managed_agent( record.persona_id.as_deref(), &reconcile_personas, record.agent_command_override.as_deref(), + Some(&record.agent_command), ); let reconcile = ProfileReconcileData { diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 2e3518b50..a3b09726f 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -252,11 +252,14 @@ pub fn default_agent_command() -> String { /// Resolution order: /// 1. explicit override (non-empty) — a deliberate per-instance pin; /// 2. the linked persona's `runtime` id mapped to its primary command; -/// 3. `default_agent_command()` — no persona/runtime, or persona deleted. +/// 3. the record's stored `agent_command` snapshot for legacy records whose +/// persona has no runtime field; +/// 4. `default_agent_command()` — no persona/runtime/snapshot, or persona deleted. pub fn effective_agent_command( persona_id: Option<&str>, personas: &[crate::managed_agents::types::PersonaRecord], agent_command_override: Option<&str>, + record_agent_command: Option<&str>, ) -> String { if let Some(pin) = agent_command_override .map(str::trim) @@ -271,6 +274,12 @@ pub fn effective_agent_command( .and_then(known_acp_runtime_exact) .and_then(|r| r.commands.first().copied()) .map(str::to_string) + .or_else(|| { + record_agent_command + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) .unwrap_or_else(default_agent_command) } @@ -293,7 +302,7 @@ pub fn divergent_agent_command_override( let picked = picked_command .map(str::trim) .filter(|value| !value.is_empty())?; - let persona_command = effective_agent_command(persona_id, personas, None); + let persona_command = effective_agent_command(persona_id, personas, None, None); let same_runtime = match ( known_acp_runtime(picked), known_acp_runtime(&persona_command), @@ -891,7 +900,7 @@ mod tests { // An explicit pin beats the persona's runtime. let personas = vec![persona_with_runtime("p1", Some("claude"))]; assert_eq!( - effective_agent_command(Some("p1"), &personas, Some("codex-acp")), + effective_agent_command(Some("p1"), &personas, Some("codex-acp"), Some("goose")), "codex-acp" ); } @@ -901,7 +910,7 @@ mod tests { // No override → persona runtime id maps to its primary command. let personas = vec![persona_with_runtime("p1", Some("claude"))]; assert_eq!( - effective_agent_command(Some("p1"), &personas, None), + effective_agent_command(Some("p1"), &personas, None, Some("goose")), "claude-agent-acp" ); } @@ -911,26 +920,37 @@ mod tests { // A blank/whitespace override is treated as "inherit", not a pin. let personas = vec![persona_with_runtime("p1", Some("goose"))]; assert_eq!( - effective_agent_command(Some("p1"), &personas, Some(" ")), + effective_agent_command(Some("p1"), &personas, Some(" "), Some("buzz-agent")), + "goose" + ); + } + + #[test] + fn effective_agent_command_falls_back_to_record_snapshot() { + // Legacy records may predate persona runtime fields. Preserve their + // stored harness instead of silently changing them to the new default. + let personas = vec![persona_with_runtime("p1", None)]; + assert_eq!( + effective_agent_command(Some("p1"), &personas, None, Some("goose")), "goose" ); } #[test] fn effective_agent_command_falls_back_to_default() { - // No override, no persona runtime, and a deleted persona all fall back - // to the bundled default. + // No override, no persona runtime, no record snapshot, and a deleted + // persona all fall back to the bundled default. let personas = vec![persona_with_runtime("p1", None)]; assert_eq!( - effective_agent_command(Some("p1"), &personas, None), + effective_agent_command(Some("p1"), &personas, None, None), default_agent_command() ); assert_eq!( - effective_agent_command(Some("gone"), &personas, None), + effective_agent_command(Some("gone"), &personas, None, None), default_agent_command() ); assert_eq!( - effective_agent_command(None, &personas, None), + effective_agent_command(None, &personas, None, None), default_agent_command() ); } diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index e5897d978..c30b3d06f 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -197,15 +197,46 @@ pub async fn restore_managed_agents_on_launch( return Ok(()); } - // Snapshot the workspace owner pubkey once for the legacy auth_tag fallback. - // Read outside the per-agent spawn loop so all parallel spawns see the same - // value and we don't lock `state.keys` repeatedly. - let owner_hex: Option = state - .keys - .lock() - .map_err(|e| e.to_string()) - .ok() - .map(|k| k.public_key().to_hex()); + // Snapshot the workspace owner once and refresh each restored agent's + // NIP-OA auth tag when the app identity changed since the record was + // created. Without this, owner-only agents can start successfully but ignore + // the current user's messages because the harness resolves an old owner. + let (owner_hex, auth_tag_updates): (Option, Vec<(String, Option, String)>) = { + let owner_keys = state.keys.lock().map_err(|e| e.to_string())?; + let owner_hex = owner_keys.public_key().to_hex(); + let mut updates = Vec::new(); + for record in &mut agents_to_start { + if super::auth_tag_matches_owner( + record.auth_tag.as_deref(), + &record.pubkey, + Some(&owner_hex), + ) { + continue; + } + let refreshed_auth_tag = + super::managed_agent_auth_tag_for_owner(&owner_keys, &record.pubkey)?; + let updated_at = util::now_iso(); + record.auth_tag = refreshed_auth_tag.clone(); + record.updated_at = updated_at.clone(); + updates.push((record.pubkey.clone(), refreshed_auth_tag, updated_at)); + } + (Some(owner_hex), updates) + }; + + if !auth_tag_updates.is_empty() { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(app)?; + for (pubkey, auth_tag, updated_at) in &auth_tag_updates { + if let Ok(record) = find_managed_agent_mut(&mut records, pubkey) { + record.auth_tag = auth_tag.clone(); + record.updated_at = updated_at.clone(); + } + } + save_managed_agents(app, &records)?; + } #[cfg(feature = "mesh-llm")] let agents_to_start = { @@ -310,6 +341,7 @@ pub async fn restore_managed_agents_on_launch( record.persona_id.as_deref(), &reconcile_personas, record.agent_command_override.as_deref(), + Some(&record.agent_command), ); Some(( pubkey.clone(), diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index d7e271df3..4fd30e387 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1410,6 +1410,7 @@ pub fn build_managed_agent_summary( record.persona_id.as_deref(), personas, record.agent_command_override.as_deref(), + Some(&record.agent_command), ); let effective_args = normalize_agent_args(&effective_command, record.agent_args.clone()); let effective_mcp_command = known_acp_runtime(&effective_command) @@ -1504,12 +1505,10 @@ pub(crate) fn build_respond_to_env( remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); } - // Legacy fallback: agents created before NIP-OA lack `auth_tag`. Without - // it the harness can't resolve the owner, and owner-dependent gate modes - // would drop every event. Forwarding the workspace owner pubkey via - // BUZZ_ACP_AGENT_OWNER keeps those records functional. Modern records - // (`auth_tag = Some(...)`) use `BUZZ_AUTH_TAG` as before. - if record.auth_tag.is_none() { + // Fallback: if the record has no usable NIP-OA `auth_tag` for the current + // workspace owner, forward the owner pubkey explicitly. This covers both + // pre-NIP-OA records and records restored after the local identity changed. + if !auth_tag_matches_owner(record.auth_tag.as_deref(), &record.pubkey, owner_hex) { if let Some(owner) = owner_hex { set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); } else { @@ -1522,6 +1521,53 @@ pub(crate) fn build_respond_to_env( Ok((set, remove)) } +pub(crate) fn auth_tag_owner_hex(auth_tag: &str, agent_pubkey_hex: &str) -> Option { + let trimmed = auth_tag.trim(); + if trimmed.is_empty() { + return None; + } + let agent_pubkey = nostr::PublicKey::from_hex(agent_pubkey_hex).ok()?; + buzz_sdk_pkg::nip_oa::verify_auth_tag(trimmed, &agent_pubkey) + .ok() + .map(|owner| owner.to_hex().to_ascii_lowercase()) +} + +pub(crate) fn auth_tag_matches_owner( + auth_tag: Option<&str>, + agent_pubkey_hex: &str, + owner_hex: Option<&str>, +) -> bool { + let Some(tag) = auth_tag.map(str::trim).filter(|tag| !tag.is_empty()) else { + return false; + }; + match owner_hex { + Some(owner) => auth_tag_owner_hex(tag, agent_pubkey_hex) + .is_some_and(|tag_owner| tag_owner.eq_ignore_ascii_case(owner)), + None => true, + } +} + +pub(crate) fn managed_agent_auth_tag_for_owner( + owner_keys: &nostr::Keys, + agent_pubkey_hex: &str, +) -> Result, String> { + if owner_keys + .public_key() + .to_hex() + .eq_ignore_ascii_case(agent_pubkey_hex) + { + return Ok(None); + } + + let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex()) + .map_err(|error| format!("failed to bridge owner keys: {error}"))?; + let compat_agent = nostr::PublicKey::from_hex(agent_pubkey_hex) + .map_err(|error| format!("failed to bridge agent pubkey: {error}"))?; + buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "") + .map(Some) + .map_err(|error| format!("failed to compute NIP-OA auth tag: {error}")) +} + /// Resolve the effective system prompt, model, and provider from the *live* /// persona for **display and model-discovery only** — the ModelPicker shows the /// current persona model as selected. The spawn and deploy paths deliberately @@ -1583,6 +1629,7 @@ pub fn spawn_agent_child( record.persona_id.as_deref(), &personas, record.agent_command_override.as_deref(), + Some(&record.agent_command), ); let agent_args = normalize_agent_args(&effective_command, record.agent_args.clone()); let resolved_acp_command = resolve_command(&record.acp_command) @@ -1733,7 +1780,13 @@ pub fn spawn_agent_child( command.env_remove("BUZZ_ACP_API_TOKEN"); command.env_remove("BUZZ_API_TOKEN"); - if let Some(ref auth_tag) = record.auth_tag { + let auth_tag_for_child = record + .auth_tag + .as_deref() + .map(str::trim) + .filter(|tag| !tag.is_empty()) + .filter(|tag| auth_tag_matches_owner(Some(tag), &record.pubkey, owner_hex)); + if let Some(auth_tag) = auth_tag_for_child { command.env("BUZZ_AUTH_TAG", auth_tag); } else { command.env_remove("BUZZ_AUTH_TAG"); diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 0060b9e35..566f40bc9 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -166,8 +166,16 @@ fn fixture( #[test] fn build_env_owner_only_sets_mode_and_removes_others() { - let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into())); - let (set, remove) = build_respond_to_env(&rec, Some("owner")).unwrap(); + let owner = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let agent_pubkey = agent.public_key().to_hex(); + let auth_tag = super::managed_agent_auth_tag_for_owner(&owner, &agent_pubkey) + .unwrap() + .unwrap(); + let mut rec = fixture(RespondTo::OwnerOnly, vec![], Some(auth_tag)); + rec.pubkey = agent_pubkey; + let owner_hex = owner.public_key().to_hex(); + let (set, remove) = build_respond_to_env(&rec, Some(&owner_hex)).unwrap(); let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); assert_eq!( set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), @@ -179,6 +187,27 @@ fn build_env_owner_only_sets_mode_and_removes_others() { assert!(remove.contains(&"BUZZ_ACP_AGENT_OWNER")); } +#[test] +fn build_env_stale_auth_tag_emits_current_owner() { + let previous_owner = nostr::Keys::generate(); + let current_owner = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let agent_pubkey = agent.public_key().to_hex(); + let stale_auth_tag = super::managed_agent_auth_tag_for_owner(&previous_owner, &agent_pubkey) + .unwrap() + .unwrap(); + let mut rec = fixture(RespondTo::OwnerOnly, vec![], Some(stale_auth_tag)); + rec.pubkey = agent_pubkey; + let current_owner_hex = current_owner.public_key().to_hex(); + let (set, remove) = build_respond_to_env(&rec, Some(¤t_owner_hex)).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + assert_eq!( + set_map.get("BUZZ_ACP_AGENT_OWNER").map(String::as_str), + Some(current_owner_hex.as_str()) + ); + assert!(!remove.contains(&"BUZZ_ACP_AGENT_OWNER")); +} + #[test] fn build_env_allowlist_sets_both_envs_and_joins() { let a = "a".repeat(64); diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 79fb0899d..ddcb3a8ab 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -18,6 +18,18 @@ import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotific import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems"; import { useTauriWindowDrag } from "@/app/useTauriWindowDrag"; import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; +import { + buildAgentConversation, + type AgentConversation, + type AgentConversationTitleStatus, + type OpenAgentConversationInput, + publishAgentConversationMarker, + readHiddenAgentConversationIds, + readPersistedAgentConversations, + writeHiddenAgentConversationIds, + writePersistedAgentConversations, +} from "@/features/agents/agentConversations"; +import { AgentConversationScreen } from "@/features/agents/ui/AgentConversationScreen"; import { channelsQueryKey, useChannelsQuery, @@ -101,6 +113,15 @@ export function AppShell() { const [isNewDmOpen, setIsNewDmOpen] = React.useState(false); const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false); const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false); + const [agentConversations, setAgentConversations] = React.useState< + AgentConversation[] + >([]); + const [hiddenAgentConversationIds, setHiddenAgentConversationIds] = + React.useState>(() => new Set()); + const [agentConversationStoragePubkey, setAgentConversationStoragePubkey] = + React.useState(null); + const [selectedAgentConversationId, setSelectedAgentConversationId] = + React.useState(null); const mainInsetRef = React.useRef(null); const location = useLocation(); const queryClient = useQueryClient(); @@ -133,14 +154,34 @@ export function AppShell() { const startupReady = useDeferredStartup(); const identityQuery = useIdentityQuery(); - const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes( - identityQuery.data?.pubkey, - ); - const { starredChannelIds, starChannel, unstarChannel } = useChannelStars( - identityQuery.data?.pubkey, - ); - usePersonaSync(identityQuery.data?.pubkey); useAgentsDataRefresh(); + const currentPubkey = identityQuery.data?.pubkey; + React.useEffect(() => { + if (!currentPubkey) { + setAgentConversations([]); + setHiddenAgentConversationIds(new Set()); + setAgentConversationStoragePubkey(null); + return; + } + + setAgentConversations(readPersistedAgentConversations(currentPubkey)); + setHiddenAgentConversationIds( + readHiddenAgentConversationIds(currentPubkey), + ); + setAgentConversationStoragePubkey(currentPubkey); + }, [currentPubkey]); + React.useEffect(() => { + if (!currentPubkey || agentConversationStoragePubkey !== currentPubkey) { + return; + } + + writePersistedAgentConversations(currentPubkey, agentConversations); + }, [agentConversationStoragePubkey, agentConversations, currentPubkey]); + const { mutedChannelIds, muteChannel, unmuteChannel } = + useChannelMutes(currentPubkey); + const { starredChannelIds, starChannel, unstarChannel } = + useChannelStars(currentPubkey); + usePersonaSync(currentPubkey); const profileQuery = useProfileQuery(); const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; useRelayAutoHeal(); @@ -196,6 +237,24 @@ export function AppShell() { ? (channels.find((channel) => channel.id === targetChannelId) ?? null) : null; }, [channels, managedChannelId, selectedChannelId]); + const selectedAgentConversation = + selectedView === "agents" && selectedAgentConversationId + ? (agentConversations.find( + (conversation) => conversation.id === selectedAgentConversationId, + ) ?? null) + : null; + const visibleAgentConversations = React.useMemo( + () => + agentConversations.filter( + (conversation) => !hiddenAgentConversationIds.has(conversation.id), + ), + [agentConversations, hiddenAgentConversationIds], + ); + const selectedAgentConversationChannel = selectedAgentConversation + ? (channels.find( + (channel) => channel.id === selectedAgentConversation.channelId, + ) ?? null) + : null; const { handleChannelNotification, @@ -421,10 +480,119 @@ export function AppShell() { const handleOpenSearchResult = React.useCallback( (hit: SearchHit) => { + setSelectedAgentConversationId(null); void openSearchHit(hit); }, [openSearchHit], ); + const handleOpenAgentConversation = React.useCallback( + ( + input: OpenAgentConversationInput, + options?: { publishMarker?: boolean }, + ) => { + const conversation = buildAgentConversation(input); + if (options?.publishMarker !== false) { + void publishAgentConversationMarker(input).catch((error) => { + console.warn("[agentConversations] marker publish failed:", error); + }); + } + if (currentPubkey) { + setHiddenAgentConversationIds((current) => { + if (!current.has(conversation.id)) { + return current; + } + + const next = new Set(current); + next.delete(conversation.id); + writeHiddenAgentConversationIds(currentPubkey, next); + return next; + }); + } + setAgentConversations((current) => { + const existingIndex = current.findIndex( + (item) => item.id === conversation.id, + ); + if (existingIndex < 0) { + return [conversation, ...current]; + } + + const next = [...current]; + next.splice(existingIndex, 1); + return [conversation, ...next]; + }); + setSelectedAgentConversationId(conversation.id); + void goAgents(); + }, + [currentPubkey, goAgents], + ); + const handleUpdateAgentConversationTitle = React.useCallback( + ( + conversationId: string, + title: string, + titleStatus: AgentConversationTitleStatus, + ) => { + setAgentConversations((current) => + current.map((conversation) => + conversation.id === conversationId + ? { ...conversation, title, titleStatus } + : conversation, + ), + ); + }, + [], + ); + const handleHideAgentConversation = React.useCallback( + (conversationId: string) => { + const conversation = + agentConversations.find((item) => item.id === conversationId) ?? null; + if (!currentPubkey) { + return; + } + + setHiddenAgentConversationIds((current) => { + if (current.has(conversationId)) { + return current; + } + + const next = new Set(current); + next.add(conversationId); + writeHiddenAgentConversationIds(currentPubkey, next); + return next; + }); + + if (selectedAgentConversationId === conversationId) { + setSelectedAgentConversationId(null); + if (conversation) { + void goChannel(conversation.channelId); + } + } + }, + [agentConversations, currentPubkey, goChannel, selectedAgentConversationId], + ); + const handleSelectAgentConversation = React.useCallback( + (conversationId: string) => { + setSelectedAgentConversationId(conversationId); + void goAgents(); + }, + [goAgents], + ); + const handleBackToAgentConversationThread = React.useCallback( + (conversation: AgentConversation) => { + setSelectedAgentConversationId(null); + void goChannel(conversation.channelId, { + messageId: conversation.agentReply.id, + threadRootId: conversation.threadRootId, + }); + }, + [goChannel], + ); + const handleSelectChannel = React.useCallback( + (channelId: string) => { + setSelectedAgentConversationId(null); + void goChannel(channelId); + }, + [goChannel], + ); // Prevent webview file:/// navigation on file drop outside the composer. // Scoped to file drags only (text drag-and-drop into inputs still works). @@ -569,9 +737,12 @@ export function AppShell() { { setManagedChannelId( @@ -663,6 +834,7 @@ export function AppShell() {
setIsAddWorkspaceOpen(true)} onUpdateWorkspace={workspacesHook.updateWorkspace} @@ -713,6 +886,7 @@ export function AppShell() { createdChannel.id, name, ); + setSelectedAgentConversationId(null); await goChannel(createdChannel.id); void applyAgents(templateId, createdChannel.id); }} @@ -737,6 +911,7 @@ export function AppShell() { createdForum.id, name, ); + setSelectedAgentConversationId(null); await goChannel(createdForum.id); void applyAgents(templateId, createdForum.id); }} @@ -750,20 +925,37 @@ export function AppShell() { await openDmMutation.mutateAsync({ pubkeys, }); + setSelectedAgentConversationId(null); await goChannel(directMessage.id); }} - onSelectAgents={() => void goAgents()} - onSelectChannel={(channelId) => - void goChannel(channelId) + onSelectAgentConversation={ + handleSelectAgentConversation } + onSelectAgents={() => { + setSelectedAgentConversationId(null); + void goAgents(); + }} + onSelectChannel={handleSelectChannel} onOpenSearchResult={handleOpenSearchResult} searchChannels={channels} searchFocusRequest={searchFocusRequest} - onSelectHome={() => void goHome()} - onSelectProjects={() => void goProjects()} - onSelectPulse={() => void goPulse()} + onSelectHome={() => { + setSelectedAgentConversationId(null); + void goHome(); + }} + onSelectProjects={() => { + setSelectedAgentConversationId(null); + void goProjects(); + }} + onSelectPulse={() => { + setSelectedAgentConversationId(null); + void goPulse(); + }} onSelectSettings={handleOpenSettings} - onSelectWorkflows={() => void goWorkflows()} + onSelectWorkflows={() => { + setSelectedAgentConversationId(null); + void goWorkflows(); + }} onSetPresenceStatus={(status) => presenceSession.setStatus(status) } @@ -785,6 +977,9 @@ export function AppShell() { : undefined } selectedChannelId={selectedChannelId} + selectedAgentConversationId={ + selectedAgentConversationId + } selectedView={selectedView} unreadChannelIds={unreadChannelIds} unreadChannelCounts={unreadChannelCounts} @@ -805,7 +1000,19 @@ export function AppShell() { - + {selectedAgentConversation ? ( + + ) : ( + + )}
@@ -828,11 +1035,10 @@ export function AppShell() { onDeleteActiveChannel={() => { setIsChannelManagementOpen(false); setManagedChannelId(null); + setSelectedAgentConversationId(null); void goHome({ replace: true }); }} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} + onSelectChannel={handleSelectChannel} /> diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index 3266210fd..de87f8dc0 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -1,4 +1,9 @@ import * as React from "react"; +import type { + AgentConversation, + AgentConversationTitleStatus, + OpenAgentConversationInput, +} from "@/features/agents/agentConversations"; import type { ContextParentResolver } from "@/features/channels/readState/readStateManager"; import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels"; import type { FeedItemState } from "@/features/home/useFeedItemState"; @@ -7,6 +12,7 @@ import type { FeedItem } from "@/shared/api/types"; const EMPTY_SET = new Set(); type AppShellContextValue = { + agentConversations: readonly AgentConversation[]; markAllChannelsRead: () => void; markChannelRead: ( channelId: string, @@ -14,6 +20,15 @@ type AppShellContextValue = { options?: { topLevelOnly?: boolean }, ) => void; markChannelUnread: (channelId: string) => void; + openAgentConversation: ( + input: OpenAgentConversationInput, + options?: { publishMarker?: boolean }, + ) => void; + updateAgentConversationTitle: ( + conversationId: string, + title: string, + titleStatus: AgentConversationTitleStatus, + ) => void; openCreateChannel: () => void; openChannelManagement: (channelId?: string) => void; // NIP-RS read marker for a channel as a unix-seconds timestamp, or null @@ -48,9 +63,12 @@ type AppShellContextValue = { }; const AppShellContext = React.createContext({ + agentConversations: [], markAllChannelsRead: () => {}, markChannelRead: () => {}, markChannelUnread: () => {}, + openAgentConversation: () => {}, + updateAgentConversationTitle: () => {}, openCreateChannel: () => {}, openChannelManagement: () => {}, getChannelReadAt: () => null, diff --git a/desktop/src/features/agents/agentConversationRecap.ts b/desktop/src/features/agents/agentConversationRecap.ts new file mode 100644 index 000000000..33c33c15d --- /dev/null +++ b/desktop/src/features/agents/agentConversationRecap.ts @@ -0,0 +1,202 @@ +import type { TimelineMessage } from "@/features/messages/types"; +import type { AgentConversationRecapInput } from "./agentConversations"; +import { + normalizeTitleToken, + sentenceCaseTitle, +} from "./agentConversationTitles"; + +function normalizeRecapComparisonText(text: string | null | undefined): string { + return (text ?? "").replace(/\s+/g, " ").trim().toLocaleLowerCase(); +} + +function isGenericRecapText(text: string): boolean { + const normalized = normalizeRecapComparisonText(text); + + return ( + normalized.length < 3 || + normalized === "thinking" || + normalized === "thinking..." || + /^what can i help you with\b/.test(normalized) || + /^of course\b.*\bwhat do you need help with\??$/.test(normalized) || + (/^(sure|okay|ok|got it|i get it|i understand)\b/.test(normalized) && + /\b(?:summarize|summary|recap)\b/.test(normalized) && + /\b(?:you want|you'd like|you're asking|you asked)\b/.test(normalized)) + ); +} + +function formatRecapMessageText(message: TimelineMessage): string | null { + const body = message.body ?? ""; + if ( + /^\s*(?:\*\*)?Outcome from continued conversation/i.test(body) || + /^\s*Please send a concise summary of this continued conversation/i.test( + body, + ) || + /^\s*Please create a concise conversation recap/i.test(body) || + /^\s*thinking\.{0,3}\s*$/i.test(body) + ) { + return null; + } + + const cleaned = body + .replace(/\r\n/g, "\n") + .replace(/```[\s\S]*?```/g, " code ") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[[^\]]*]\([^)]+\)/g, "media") + .replace(/https?:\/\/\S+/g, "link") + .replace(/@\S+/g, "") + .replace(/^[\s,.:;-]*(ok|okay|so|also|then|and then|um|uh)[\s,.:;-]+/i, "") + .replace(/^(i think|i guess|i wonder if|maybe|basically)[\s,.:;-]+/i, "") + .replace(/^(can|could|would) (you|we)\s+/i, "") + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim() + .replace(/[.!?]+$/, ""); + + if (!cleaned || isGenericRecapText(cleaned)) { + return null; + } + + return sentenceCaseTitle(cleaned); +} + +function isSameRecapPoint( + left: string | null | undefined, + right: string | null | undefined, +) { + return ( + normalizeRecapComparisonText(left) === normalizeRecapComparisonText(right) + ); +} + +function appendUniqueRecapPoint(points: string[], point: string | null) { + if (!point) { + return; + } + + if (points.some((current) => isSameRecapPoint(current, point))) { + return; + } + + points.push(point); +} + +function normalizeInlineOrderedListBreaks(value: string): string { + const itemMatches = [...value.matchAll(/(?:^|\s)(\d+)\.\s+/g)]; + if (itemMatches.length < 2) { + return value; + } + + return value.replace(/\s+(?=\d+\.\s+)/g, "\n"); +} + +function formatRecapSection( + label: string, + value: string | null, +): string | null { + if (!value) { + return null; + } + + const formattedValue = normalizeInlineOrderedListBreaks(value); + const firstListIndex = formattedValue.search(/(?:^|\n)\d+\.\s/); + if (firstListIndex < 0) { + return `**${label}:** ${formattedValue}`; + } + + const preface = formattedValue.slice(0, firstListIndex).trim(); + const list = formattedValue.slice(firstListIndex).trim(); + + return preface + ? `**${label}:** ${preface}\n\n${list}` + : `**${label}:**\n\n${list}`; +} + +function singleLineRecapText(value: string | null): string | null { + if (!value) { + return null; + } + + return value.replace(/\s+/g, " ").trim(); +} + +export function buildAgentConversationRecap({ + agentPubkeys, + messages, +}: AgentConversationRecapInput): string | null { + const normalizedAgentPubkeys = new Set( + [...agentPubkeys].map((pubkey) => normalizeTitleToken(pubkey)), + ); + const usableMessages = [...messages] + .flatMap((message, originalIndex) => { + const text = formatRecapMessageText(message); + if (!text) { + return []; + } + + return [ + { + isAgent: + message.pubkey != null && + normalizedAgentPubkeys.has(normalizeTitleToken(message.pubkey)), + message, + originalIndex, + text, + }, + ]; + }) + .sort( + (left, right) => + left.message.createdAt - right.message.createdAt || + left.originalIndex - right.originalIndex, + ); + + if (usableMessages.length === 0) { + return null; + } + + const humanMessages = usableMessages.filter((entry) => !entry.isAgent); + const agentMessages = usableMessages.filter((entry) => entry.isAgent); + const firstHumanText = humanMessages[0]?.text ?? null; + const latestHumanText = humanMessages[humanMessages.length - 1]?.text ?? null; + const originalRequest = + firstHumanText && + latestHumanText && + !isSameRecapPoint(firstHumanText, latestHumanText) + ? `${singleLineRecapText(firstHumanText)} Later clarified: ${singleLineRecapText(latestHumanText)}` + : firstHumanText; + + const outcomeMessage = [...agentMessages].reverse()[0] ?? null; + const latestAgentByPubkey = new Map(); + for (const entry of agentMessages) { + if (entry.message.id === outcomeMessage?.message.id) { + continue; + } + + latestAgentByPubkey.set( + normalizeTitleToken(entry.message.pubkey ?? entry.message.author), + entry, + ); + } + const findingPoints: string[] = []; + for (const entry of [...latestAgentByPubkey.values()].slice(-3)) { + const prefix = + latestAgentByPubkey.size > 1 ? `${entry.message.author}: ` : ""; + appendUniqueRecapPoint(findingPoints, `${prefix}${entry.text}`); + } + const findings = findingPoints.join(" ") || null; + const outcome = outcomeMessage?.text ?? null; + + const latestMessage = usableMessages[usableMessages.length - 1]; + const nextSteps = + !latestMessage.isAgent && !isSameRecapPoint(latestHumanText, firstHumanText) + ? `Follow up on the latest question: ${latestMessage.text}` + : null; + const sections = [ + formatRecapSection("Original request", originalRequest), + formatRecapSection("Findings", findings), + formatRecapSection("Outcome", outcome), + formatRecapSection("Next steps", nextSteps), + ].filter((section): section is string => section !== null); + + return sections.length > 0 ? sections.join("\n\n") : null; +} diff --git a/desktop/src/features/agents/agentConversationTitles.ts b/desktop/src/features/agents/agentConversationTitles.ts new file mode 100644 index 000000000..49dd8addb --- /dev/null +++ b/desktop/src/features/agents/agentConversationTitles.ts @@ -0,0 +1,582 @@ +import type { TimelineMessage } from "@/features/messages/types"; +import type { + AgentConversation, + AgentConversationTitleStatus, + OpenAgentConversationInput, +} from "./agentConversations"; + +const MIN_CONTEXT_MESSAGES_FOR_TOPIC_TITLE = 3; +const MIN_MEANINGFUL_HUMAN_MESSAGES_FOR_TOPIC_TITLE = 2; +const CONCISE_TITLE_MAX_WORDS = 5; +const CONCISE_TITLE_MAX_CHARS = 44; +const GENERIC_REFERENCE_WORDS = new Set([ + "actually", + "again", + "also", + "bit", + "even", + "half", + "just", + "kind", + "little", + "maybe", + "more", + "much", + "really", + "same", + "slightly", + "sort", + "still", + "thing", + "things", +]); +const TITLE_STOP_WORDS = new Set([ + "a", + "about", + "an", + "and", + "app", + "are", + "as", + "be", + "but", + "by", + "can", + "could", + "did", + "do", + "does", + "for", + "from", + "get", + "had", + "has", + "have", + "having", + "help", + "how", + "i", + "if", + "in", + "into", + "is", + "it", + "its", + "kind", + "kinds", + "like", + "me", + "mean", + "meant", + "of", + "on", + "or", + "our", + "please", + "product", + "that", + "the", + "their", + "them", + "there", + "tell", + "this", + "to", + "type", + "types", + "us", + "was", + "we", + "what", + "when", + "where", + "which", + "with", + "work", + "working", + "would", + "you", + "your", +]); +const TOPIC_TOKEN_PRIORITY = new Map([ + ["animation", 18], + ["composer", 18], + ["conversation", 18], + ["conversations", 18], + ["data", 50], + ["header", 18], + ["link", 18], + ["message", 14], + ["messages", 14], + ["padding", 18], + ["search", 18], + ["sidebar", 18], + ["spacing", 18], + ["thread", 18], + ["threads", 18], + ["title", 22], + ["titles", 22], + ["user", 16], + ["users", 16], +]); +const TOPIC_ANCHOR_SUFFIX = + "app|product|workspace|relay|channel|thread|conversation|sidebar|composer|header|inbox|panel|title|link|button|row|animation|shimmer|screen|view"; +const TOPIC_ANCHOR_PATTERN = new RegExp( + `\\b(?:the\\s+)?([A-Z][A-Za-z0-9_-]*(?:\\s+[A-Z][A-Za-z0-9_-]+){0,2}\\s+(?:${TOPIC_ANCHOR_SUFFIX}))\\b`, + "g", +); + +function compactMessageText(message: TimelineMessage | null): string | null { + if ( + /^\s*(?:\*\*)?Outcome from continued conversation/i.test( + message?.body ?? "", + ) || + /^\s*Please send a concise summary of this continued conversation/i.test( + message?.body ?? "", + ) || + /^\s*Please create a concise conversation recap/i.test( + message?.body ?? "", + ) || + /^\s*thinking\.{0,3}\s*$/i.test(message?.body ?? "") + ) { + return null; + } + + const compact = message?.body + .replace(/```[\s\S]*?```/g, " code ") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[[^\]]*]\([^)]+\)/g, "media") + .replace(/https?:\/\/\S+/g, "link") + .replace(/@\S+/g, "") + .replace(/^[\s,.:;-]*(ok|okay|so|also|then|and then|um|uh)[\s,.:;-]+/i, "") + .replace(/^(i think|i guess|i wonder if|maybe|basically)[\s,.:;-]+/i, "") + .replace(/^(can|could|would) (you|we)\s+/i, "") + .replace(/\s+/g, " ") + .trim() + .replace(/[.!?]+$/, ""); + + if (!compact) { + return null; + } + + return compact; +} + +function normalizeWorkTitleText(text: string): string { + let normalized = text; + + for (let index = 0; index < 4; index += 1) { + const next = normalized + .replace(/^(?:i\s+)?(?:mean|meant),?\s+/i, "") + .replace(/^(?:i\s+)?(?:think|guess|wonder)(?:\s+that)?(?:\s+if)?\s+/i, "") + .replace(/^(?:can|could|would|should)\s+(?:you|we)\s+/i, "") + .replace(/^(?:i\s+)?(?:just\s+)?(?:want|wanted)\s+(?:to\s+)?/i, "") + .replace(/^(?:what\s+)?(?:i\s+)?(?:would\s+)?like\s+(?:is\s+)?/i, "") + .replace(/^(?:also|okay|ok|so|then|and then|actually)\s+/i, "") + .replace(/^(?:just|maybe)\s+/i, "") + .trim(); + + if (next === normalized) { + break; + } + normalized = next; + } + + return normalized + .replace(/\b(?:like|basically|kind of|sort of)\b[,\s]*/gi, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function isGenericConversationTitle(text: string): boolean { + const normalized = text.toLowerCase(); + + return ( + /^(respond|reply|answer|can respond|can reply)$/.test(normalized) || + /^what can i help you with\b/.test(normalized) || + /^of course\b/.test(normalized) || + /^(thanks|thank you|got it|sounds good)$/.test(normalized) + ); +} + +function formatConversationTitle(text: string): string { + const sentenceEnd = text.search(/[.!?]\s/); + const candidate = sentenceEnd > 12 ? text.slice(0, sentenceEnd).trim() : text; + const words = candidate.split(" "); + const title = + words.length > CONCISE_TITLE_MAX_WORDS + ? words.slice(0, CONCISE_TITLE_MAX_WORDS).join(" ") + : candidate; + + return title.length > CONCISE_TITLE_MAX_CHARS + ? `${title.slice(0, CONCISE_TITLE_MAX_CHARS - 3).trimEnd()}...` + : title; +} + +export function sentenceCaseTitle(text: string): string { + if (!text) { + return text; + } + + return `${text.charAt(0).toLocaleUpperCase()}${text.slice(1)}`; +} + +function titleCaseToken(token: string): string { + if (token.toUpperCase() === token && token.length <= 4) { + return token; + } + + return `${token.charAt(0).toLocaleUpperCase()}${token.slice(1).toLocaleLowerCase()}`; +} + +export function normalizeTitleToken(token: string): string { + const normalized = token + .toLocaleLowerCase() + .replace(/'s$/, "") + .replace(/[^a-z0-9_-]/g, ""); + + if (normalized.endsWith("ies") && normalized.length > 4) { + return `${normalized.slice(0, -3)}y`; + } + if (normalized.endsWith("s") && normalized.length > 4) { + return normalized.slice(0, -1); + } + + return normalized; +} + +function extractConciseTopicPhrase(text: string): string | null { + const normalized = normalizeWorkTitleText(text) + .replace( + /^(?:tell me about|talk about|explain|describe|summarize|look into|look at|check|review|investigate)\s+/i, + "", + ) + .replace( + /^what\s+(?:kind|types?)\s+of\s+(.+?)(?:\s+(?:do|does|did|is|are|we|you)\b|$).*/i, + "$1", + ) + .replace( + /^what\s+(.+?)\s+(?:do|does|did|can|could|would|should|is|are)\b.*$/i, + "$1", + ) + .replace( + /\b(?:do\s+)?(?:we|you|i)\s+(?:have|store|collect|track|use|show|need|want)\b/gi, + "", + ) + .replace( + /\b(?:about|around|for|of)\s+(?:how|what|why|when|where|whether)\b.*$/i, + "", + ) + .replace(/\b(?:so that|because|when|if|whether)\b.*$/i, "") + .replace(/\s+/g, " ") + .trim() + .replace(/[.!?]+$/, ""); + + if (!normalized) { + return null; + } + + return formatConversationTitle(normalized); +} + +function titleFromMessage( + message: TimelineMessage | null, + options?: { allowGeneric?: boolean; workTitle?: boolean }, +): string | null { + const compact = compactMessageText(message); + if (!compact) { + return null; + } + + const title = sentenceCaseTitle( + formatConversationTitle( + options?.workTitle + ? (extractConciseTopicPhrase(compact) ?? + normalizeWorkTitleText(compact)) + : compact, + ), + ); + if (!title) { + return null; + } + + if (!options?.allowGeneric && isGenericConversationTitle(title)) { + return null; + } + + return title; +} + +function countSpecificTitleTokens(title: string): number { + return title + .toLowerCase() + .split(/[^a-z0-9_-]+/) + .filter((token) => { + if (token.length <= 2) { + return false; + } + if (TITLE_STOP_WORDS.has(token)) { + return false; + } + if (GENERIC_REFERENCE_WORDS.has(token)) { + return false; + } + + return true; + }).length; +} + +function isReferentialTitle(title: string): boolean { + const normalized = title.toLowerCase(); + if (!/\b(?:it|that|this|those|these|them|one)\b/.test(normalized)) { + return false; + } + + return countSpecificTitleTokens(title) < 3; +} + +function extractTopicAnchors(text: string): string[] { + TOPIC_ANCHOR_PATTERN.lastIndex = 0; + + return [...text.matchAll(TOPIC_ANCHOR_PATTERN)] + .map((match) => match[1]?.trim()) + .filter((anchor): anchor is string => Boolean(anchor)); +} + +function pickTopicAnchor(texts: readonly string[]): string | null { + const anchors = new Map(); + + texts.forEach((text, index) => { + for (const anchor of extractTopicAnchors(text)) { + const key = anchor.toLocaleLowerCase(); + const current = anchors.get(key); + const score = 24 + index * 5 + anchor.split(/\s+/).length * 2; + anchors.set(key, { + display: current?.display ?? anchor, + score: (current?.score ?? 0) + score, + }); + } + }); + + return ( + [...anchors.values()].sort((left, right) => right.score - left.score)[0] + ?.display ?? null + ); +} + +function pickPrimaryTopicTerm(texts: readonly string[]): { + display: string; + normalized: string; +} | null { + const terms = new Map< + string, + { display: string; firstSeen: number; score: number } + >(); + + texts.forEach((text, index) => { + const phrase = + extractConciseTopicPhrase(text) ?? normalizeWorkTitleText(text); + for (const match of phrase.matchAll(/[A-Za-z][A-Za-z0-9_-]*/g)) { + const rawToken = match[0]; + const normalized = normalizeTitleToken(rawToken); + if ( + !normalized || + TITLE_STOP_WORDS.has(normalized) || + GENERIC_REFERENCE_WORDS.has(normalized) + ) { + continue; + } + + const priority = + TOPIC_TOKEN_PRIORITY.get(normalized) ?? + TOPIC_TOKEN_PRIORITY.get(rawToken.toLocaleLowerCase()) ?? + 0; + const current = terms.get(normalized); + terms.set(normalized, { + display: current?.display ?? titleCaseToken(rawToken), + firstSeen: current?.firstSeen ?? index, + score: (current?.score ?? 0) + 8 + index * 3 + priority, + }); + } + }); + + const best = [...terms.entries()].sort( + (left, right) => + right[1].score - left[1].score || left[1].firstSeen - right[1].firstSeen, + )[0]; + + if (!best || best[1].score < 10) { + return null; + } + + return { display: best[1].display, normalized: best[0] }; +} + +function deriveConciseContextTitle({ + contextMessages, + normalizedAgentPubkey, +}: { + contextMessages: TimelineMessage[]; + normalizedAgentPubkey: string; +}): string | null { + const humanTexts = contextMessages + .filter( + (message) => + message.pubkey?.toLocaleLowerCase() !== normalizedAgentPubkey, + ) + .map((message) => compactMessageText(message)) + .filter((text): text is string => Boolean(text)); + + if (humanTexts.length === 0) { + return null; + } + + const anchor = pickTopicAnchor(humanTexts); + const primaryTerm = pickPrimaryTopicTerm(humanTexts); + if (anchor && primaryTerm) { + const normalizedAnchor = anchor.toLocaleLowerCase(); + if (!normalizedAnchor.includes(primaryTerm.normalized)) { + return `${primaryTerm.display} in ${anchor}`; + } + + return sentenceCaseTitle(anchor); + } + + const latestSpecificPhrase = [...humanTexts] + .reverse() + .map((text) => extractConciseTopicPhrase(text)) + .find( + (title): title is string => + title != null && + !isReferentialTitle(title) && + countSpecificTitleTokens(title) > 0, + ); + + return latestSpecificPhrase ? sentenceCaseTitle(latestSpecificPhrase) : null; +} + +export function collectConversationContextMessages( + input: OpenAgentConversationInput, + threadRootId: string, +): TimelineMessage[] { + const byId = new Map(); + const add = (message: TimelineMessage | null | undefined) => { + if (message) { + byId.set(message.id, message); + } + }; + + add(input.threadRootMessage); + add(input.parentMessage); + add(input.agentReply); + + for (const message of input.contextMessages ?? []) { + if ( + message.id === threadRootId || + message.id === input.agentReply.id || + message.rootId === threadRootId || + message.parentId === threadRootId + ) { + add(message); + } + } + + return [...byId.values()].sort( + (left, right) => left.createdAt - right.createdAt, + ); +} + +export function deriveTitleFromContext({ + agentPubkey, + agentReply, + contextMessages, + parentMessage, + threadRootId, + threadRootMessage, +}: { + agentPubkey: string; + agentReply: TimelineMessage; + contextMessages: TimelineMessage[]; + parentMessage: TimelineMessage | null; + threadRootId: string; + threadRootMessage: TimelineMessage | null; +}): { status: AgentConversationTitleStatus; title: string } { + const normalizedAgentPubkey = agentPubkey.toLowerCase(); + const titleCandidates = contextMessages.flatMap((message, index) => { + const isAgentMessage = + message.pubkey?.toLowerCase() === normalizedAgentPubkey; + const title = titleFromMessage(message, { workTitle: !isAgentMessage }); + if (!title) { + return []; + } + + let score = Math.min(title.length, 80) + index * 10; + if (!isAgentMessage) score += 120; + if (message.id === threadRootId) score -= 20; + if (message.id === parentMessage?.id) score += 10; + if (message.id === agentReply.id) score += isAgentMessage ? -30 : 10; + score += countSpecificTitleTokens(title) * 12; + if (isReferentialTitle(title)) score -= 80; + + return [ + { + isAgentMessage, + isReferential: isReferentialTitle(title), + score, + title, + }, + ]; + }); + const humanTitleCandidates = titleCandidates.filter( + (candidate) => !candidate.isAgentMessage, + ); + const meaningfulHumanCount = humanTitleCandidates.length; + const hasEnoughContext = + contextMessages.length >= MIN_CONTEXT_MESSAGES_FOR_TOPIC_TITLE || + meaningfulHumanCount >= MIN_MEANINGFUL_HUMAN_MESSAGES_FOR_TOPIC_TITLE; + + if (!hasEnoughContext) { + return { status: "provisional", title: "New conversation" }; + } + + const conciseContextTitle = deriveConciseContextTitle({ + contextMessages, + normalizedAgentPubkey, + }); + if (conciseContextTitle) { + return { status: "resolved", title: conciseContextTitle }; + } + + const latestSpecificHumanTitle = [...humanTitleCandidates] + .reverse() + .find((candidate) => !candidate.isReferential)?.title; + const latestHumanTitle = + latestSpecificHumanTitle ?? [...humanTitleCandidates].reverse()[0]?.title; + const bestTitle = + latestHumanTitle ?? + titleCandidates.sort((left, right) => right.score - left.score)[0]?.title; + + return { + status: bestTitle ? "resolved" : "provisional", + title: + bestTitle ?? + titleFromMessage(threadRootMessage, { allowGeneric: true }) ?? + titleFromMessage(parentMessage, { allowGeneric: true }) ?? + titleFromMessage(agentReply, { allowGeneric: true }) ?? + "New conversation", + }; +} + +export function deriveAgentConversationTitle( + conversation: Pick< + AgentConversation, + | "agentPubkey" + | "agentReply" + | "contextMessages" + | "parentMessage" + | "threadRootId" + | "threadRootMessage" + >, +): { status: AgentConversationTitleStatus; title: string } { + return deriveTitleFromContext(conversation); +} diff --git a/desktop/src/features/agents/agentConversations.test.mjs b/desktop/src/features/agents/agentConversations.test.mjs new file mode 100644 index 000000000..99f32d7cc --- /dev/null +++ b/desktop/src/features/agents/agentConversations.test.mjs @@ -0,0 +1,409 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildAgentConversationMentionPubkeys, + buildAgentConversation, + buildAgentConversationRecap, + buildAgentConversationMarkers, + deriveAgentConversationTitle, + getAutoRoutedAgentConversationPubkeys, + getHiddenAgentConversationMessageIds, + parseAgentConversationMarker, + readPersistedAgentConversations, + writePersistedAgentConversations, +} from "./agentConversations.ts"; + +function message({ body, createdAt, id, pubkey = "human" }) { + return { + author: pubkey === "agent" ? "Fizz" : "Kenny Lopez", + body, + createdAt, + depth: id === "root" ? 0 : 1, + id, + parentId: id === "root" ? null : "root", + pubkey, + rootId: id === "root" ? null : "root", + time: "1:00 PM", + }; +} + +test("continued conversation title condenses a refined Buzz data thread", () => { + const root = message({ + body: "Can you tell me about what kind of data we have in the Buzz app?", + createdAt: 1, + id: "root", + }); + const agentReply = message({ + body: "Sure, the app has channel, message, and membership data.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const refinement = message({ + body: "I meant, what data do we have about how the users use the product?", + createdAt: 3, + id: "refinement", + }); + + const title = deriveAgentConversationTitle({ + agentPubkey: "agent", + agentReply, + contextMessages: [root, agentReply, refinement], + parentMessage: root, + threadRootId: root.id, + threadRootMessage: root, + }); + + assert.deepEqual(title, { + status: "resolved", + title: "Data in Buzz app", + }); +}); + +test("continued conversation auto-routes only a single messageable agent", () => { + assert.deepEqual( + getAutoRoutedAgentConversationPubkeys([ + { canMessage: true, pubkey: "agent-one" }, + ]), + ["agent-one"], + ); + + assert.deepEqual( + getAutoRoutedAgentConversationPubkeys([ + { canMessage: true, pubkey: "agent-one" }, + { canMessage: true, pubkey: "agent-two" }, + ]), + [], + ); + + assert.deepEqual( + getAutoRoutedAgentConversationPubkeys([ + { canMessage: false, pubkey: "agent-one" }, + ]), + [], + ); +}); + +test("continued conversation mention routing preserves explicit multi-agent mentions", () => { + assert.deepEqual( + buildAgentConversationMentionPubkeys({ + autoRouteAgentPubkeys: [], + mentionPubkeys: ["agent-one"], + }), + ["agent-one"], + ); + + assert.deepEqual( + buildAgentConversationMentionPubkeys({ + autoRouteAgentPubkeys: ["AGENT-ONE"], + mentionPubkeys: ["agent-one", "agent-two"], + }), + ["AGENT-ONE", "agent-two"], + ); +}); + +function markerEvent({ content = {}, createdAt = 1, id = "marker" } = {}) { + return { + id, + pubkey: "starter", + created_at: createdAt, + kind: 40004, + tags: [ + ["h", "channel"], + ["e", "root", "", "root"], + ["e", "agent-reply", "", "agent-reply"], + ["p", "agent"], + ["title", "Data in Buzz app"], + ], + content: JSON.stringify({ + version: 1, + title: "Data in Buzz app", + titleStatus: "resolved", + agentName: "Fizz", + agentPubkey: "agent", + threadRootId: "root", + threadRootMessageId: "root", + parentMessageId: "root", + agentReplyId: "agent-reply", + ...content, + }), + sig: "sig", + }; +} + +function withMockLocalStorage(callback) { + const originalWindow = globalThis.window; + const store = new Map(); + globalThis.window = { + localStorage: { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, String(value)), + }, + }; + + try { + callback(); + } finally { + if (originalWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window = originalWindow; + } + } +} + +test("continued conversation marker parses summary metadata", () => { + const marker = parseAgentConversationMarker( + markerEvent({ + content: { + summary: "Buzz stores channel, message, and usage data.", + summaryAuthorName: "Fizz", + summaryAuthorPubkey: "agent", + summaryCreatedAt: 12, + }, + }), + ); + + assert.equal( + marker?.summary, + "Buzz stores channel, message, and usage data.", + ); + assert.equal(marker?.summaryAuthorName, "Fizz"); + assert.equal(marker?.summaryAuthorPubkey, "agent"); + assert.equal(marker?.summaryCreatedAt, 12); +}); + +test("continued conversations persist across app restarts", () => { + withMockLocalStorage(() => { + const root = message({ + body: "Can you look at the Buzz data model?", + createdAt: 1, + id: "root", + }); + const agentReply = message({ + body: "I can look at it.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const conversation = buildAgentConversation({ + agentName: "Fizz", + agentPubkey: "agent", + agentReply, + channel: { id: "channel", name: "general" }, + contextMessages: [root, agentReply], + parentMessage: root, + threadRootMessage: root, + }); + + writePersistedAgentConversations("human", [conversation]); + const persisted = readPersistedAgentConversations("human"); + + assert.equal(persisted.length, 1); + assert.equal(persisted[0].id, conversation.id); + assert.equal(persisted[0].channelId, "channel"); + assert.equal(persisted[0].agentReply.id, "agent-reply"); + }); +}); + +test("continued conversation marker summary update replaces earlier marker", () => { + const markers = buildAgentConversationMarkers([ + markerEvent({ + content: { + startedAt: 10, + summary: "Buzz stores channel, message, and usage data.", + summaryAuthorName: "Fizz", + summaryAuthorPubkey: "agent", + summaryCreatedAt: 12, + }, + createdAt: 2, + id: "second", + }), + markerEvent({ content: { startedAt: 1 }, createdAt: 1, id: "first" }), + ]); + + assert.equal(markers.length, 1); + assert.equal(markers[0].eventId, "second"); + assert.equal( + markers[0].summary, + "Buzz stores channel, message, and usage data.", + ); + assert.equal(markers[0].startedAt, 1); +}); + +test("continued conversation marker keeps recap across title-only updates", () => { + const markers = buildAgentConversationMarkers([ + markerEvent({ + content: { + startedAt: 1, + summary: "Buzz stores channel, message, and usage data.", + summaryAuthorName: "Fizz", + summaryAuthorPubkey: "agent", + summaryCreatedAt: 12, + }, + createdAt: 2, + id: "summary", + }), + markerEvent({ + content: { + startedAt: 1, + title: "Updated Buzz data topic", + }, + createdAt: 3, + id: "title-only", + }), + ]); + + assert.equal(markers.length, 1); + assert.equal(markers[0].eventId, "title-only"); + assert.equal(markers[0].title, "Updated Buzz data topic"); + assert.equal( + markers[0].summary, + "Buzz stores channel, message, and usage data.", + ); + assert.equal(markers[0].summaryAuthorName, "Fizz"); +}); + +test("continued conversation recap summarizes full conversation context", () => { + const root = message({ + body: "Can you tell me about what kind of data we have in the Buzz app?", + createdAt: 1, + id: "root", + }); + const agentReply = message({ + body: "Sure, Buzz stores channel, message, and membership data.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const refinement = message({ + body: "What data do we have about how users use the product?", + createdAt: 3, + id: "refinement", + }); + const finalAnswer = message({ + body: "For usage, Buzz tracks:\n1. Channel participation\n2. Message activity\n3. Thread engagement signals.", + createdAt: 4, + id: "final-answer", + pubkey: "agent", + }); + + const recap = buildAgentConversationRecap({ + agentPubkeys: new Set(["agent"]), + conversationTitle: "Data in Buzz app", + messages: [root, agentReply, refinement, finalAnswer], + }); + + assert.match(recap ?? "", /\*\*Original request:\*\*/); + assert.match(recap ?? "", /Later clarified:/); + assert.match(recap ?? "", /\*\*Findings:\*\*/); + assert.match(recap ?? "", /\*\*Outcome:\*\*/); + assert.match(recap ?? "", /usage/i); + assert.match( + recap ?? "", + /\*\*Outcome:\*\* For usage, Buzz tracks:\n\n1\. Channel participation\n2\. Message activity\n3\. Thread engagement signals/, + ); + assert.doesNotMatch(recap ?? "", /1\. Channel participation 2\./); + assert.doesNotMatch(recap ?? "", /^- Topic:/m); + assert.doesNotMatch(recap ?? "", /Agent response:/); + assert.doesNotMatch(recap ?? "", /Current state:/); +}); + +test("continued conversation recap keeps long outcome text", () => { + const root = message({ + body: "Can you summarize the button patterns in Buzz?", + createdAt: 1, + id: "root", + }); + const longOutcome = `${"Buzz has several button variants and sizing patterns. ".repeat(30)}Final implementation note: keep the full recap visible without truncation.`; + const agentReply = message({ + body: longOutcome, + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + + const recap = buildAgentConversationRecap({ + agentPubkeys: new Set(["agent"]), + messages: [root, agentReply], + }); + + assert.match( + recap ?? "", + /Final implementation note: keep the full recap visible without truncation/, + ); + assert.doesNotMatch(recap ?? "", /\.\.\.$/); +}); + +test("continued conversation marker hides source-thread messages after its anchor", () => { + const root = message({ + body: "Can you look into the data model?", + createdAt: 1, + id: "root", + }); + const agentReply = message({ + body: "I'll look into it.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const beforeMarker = message({ + body: "One note before opening.", + createdAt: 3, + id: "before", + }); + const afterMarker = message({ + body: "This belongs in the dedicated conversation.", + createdAt: 5, + id: "after", + pubkey: "agent", + }); + + const marker = parseAgentConversationMarker( + markerEvent({ content: { startedAt: 4 }, createdAt: 4 }), + ); + + const hiddenIds = getHiddenAgentConversationMessageIds( + [root, agentReply, beforeMarker, afterMarker], + marker ? [marker] : [], + ); + + assert.deepEqual([...hiddenIds], ["before", "after"]); +}); + +test("continued conversation marker hides same-second messages after the anchor", () => { + const root = message({ + body: "Can you look into the data model?", + createdAt: 1, + id: "root", + }); + const beforeMarker = message({ + body: "One note before opening.", + createdAt: 4, + id: "before", + }); + const agentReply = message({ + body: "I'll look into it.", + createdAt: 4, + id: "agent-reply", + pubkey: "agent", + }); + const afterMarker = message({ + body: "Still working through this.", + createdAt: 4, + id: "after", + pubkey: "agent", + }); + + const marker = parseAgentConversationMarker( + markerEvent({ content: { startedAt: 4 }, createdAt: 4 }), + ); + + const hiddenIds = getHiddenAgentConversationMessageIds( + [root, beforeMarker, agentReply, afterMarker], + marker ? [marker] : [], + ); + + assert.deepEqual([...hiddenIds], ["after"]); +}); diff --git a/desktop/src/features/agents/agentConversations.ts b/desktop/src/features/agents/agentConversations.ts new file mode 100644 index 000000000..cf93fed4a --- /dev/null +++ b/desktop/src/features/agents/agentConversations.ts @@ -0,0 +1,680 @@ +import type { TimelineMessage } from "@/features/messages/types"; +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import type { Channel, RelayEvent } from "@/shared/api/types"; +import { + KIND_AGENT_CONVERSATION, + KIND_AGENT_CONVERSATION_COMPAT, +} from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + collectConversationContextMessages, + deriveTitleFromContext, +} from "./agentConversationTitles"; + +export { buildAgentConversationRecap } from "./agentConversationRecap"; +export { deriveAgentConversationTitle } from "./agentConversationTitles"; + +const HIDDEN_AGENT_CONVERSATIONS_STORAGE_PREFIX = + "buzz-hidden-agent-conversations.v1"; +const AGENT_CONVERSATIONS_STORAGE_PREFIX = "buzz-agent-conversations.v1"; +const MAX_PERSISTED_AGENT_CONVERSATIONS = 100; +export type AgentConversationTitleStatus = "provisional" | "resolved"; + +export type AgentConversation = { + id: string; + agentName: string; + agentPubkey: string; + agentReply: TimelineMessage; + channelId: string; + channelName: string; + contextMessages: TimelineMessage[]; + createdAt: number; + parentMessage: TimelineMessage | null; + threadRootId: string; + threadRootMessage: TimelineMessage | null; + title: string; + titleStatus: AgentConversationTitleStatus; +}; + +export type OpenAgentConversationInput = { + agentName: string; + agentPubkey: string; + agentReply: TimelineMessage; + channel: Pick; + contextMessages?: TimelineMessage[]; + parentMessage: TimelineMessage | null; + threadRootMessage: TimelineMessage | null; +}; + +export type AgentConversationMarker = { + agentName: string; + agentPubkey: string; + agentReplyId: string; + channelId: string; + createdAt: number; + eventId: string; + parentMessageId: string | null; + startedAt: number; + starterPubkey: string; + summary: string | null; + summaryAuthorName: string | null; + summaryAuthorPubkey: string | null; + summaryCreatedAt: number | null; + threadRootMessageId: string | null; + threadRootId: string; + title: string; + titleStatus: AgentConversationTitleStatus; +}; + +export type AgentConversationMarkerUpdate = { + summary?: string | null; + summaryAuthorName?: string | null; + summaryAuthorPubkey?: string | null; + summaryCreatedAt?: number | null; +}; + +export type AgentConversationRecapInput = { + agentPubkeys: ReadonlySet | readonly string[]; + conversationTitle?: string | null; + messages: readonly TimelineMessage[]; +}; + +export type AgentConversationRouteableParticipant = { + canMessage: boolean; + pubkey: string; +}; + +export function hiddenAgentConversationsStorageKey(pubkey: string): string { + return `${HIDDEN_AGENT_CONVERSATIONS_STORAGE_PREFIX}:${pubkey}`; +} + +export function agentConversationsStorageKey(pubkey: string): string { + return `${AGENT_CONVERSATIONS_STORAGE_PREFIX}:${pubkey}`; +} + +export function getAutoRoutedAgentConversationPubkeys( + participants: readonly AgentConversationRouteableParticipant[], +): string[] { + if (participants.length !== 1) { + return []; + } + + const [participant] = participants; + return participant.canMessage ? [participant.pubkey] : []; +} + +export function buildAgentConversationMentionPubkeys({ + autoRouteAgentPubkeys, + mentionPubkeys, +}: { + autoRouteAgentPubkeys: readonly string[]; + mentionPubkeys: readonly string[]; +}): string[] { + const seenPubkeys = new Set(); + const merged: string[] = []; + const add = (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (!normalized || seenPubkeys.has(normalized)) { + return; + } + + seenPubkeys.add(normalized); + merged.push(pubkey); + }; + + for (const pubkey of autoRouteAgentPubkeys) { + add(pubkey); + } + for (const pubkey of mentionPubkeys) { + add(pubkey); + } + + return merged; +} + +export function readHiddenAgentConversationIds(pubkey: string): Set { + try { + const raw = window.localStorage.getItem( + hiddenAgentConversationsStorageKey(pubkey), + ); + if (!raw) { + return new Set(); + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return new Set(); + } + + return new Set( + parsed.filter((value): value is string => typeof value === "string"), + ); + } catch { + return new Set(); + } +} + +export function writeHiddenAgentConversationIds( + pubkey: string, + ids: ReadonlySet, +): void { + try { + window.localStorage.setItem( + hiddenAgentConversationsStorageKey(pubkey), + JSON.stringify([...ids]), + ); + } catch { + // Best-effort local preference; ignore storage failures. + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function maybeString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function maybeNullableString(value: unknown): string | null | undefined { + if (value === null) { + return null; + } + + return maybeString(value); +} + +function parseStoredTimelineMessage(value: unknown): TimelineMessage | null { + if (!isRecord(value)) { + return null; + } + + const id = maybeString(value.id); + const author = maybeString(value.author); + const body = maybeString(value.body); + const createdAt = + typeof value.createdAt === "number" && Number.isFinite(value.createdAt) + ? value.createdAt + : null; + if (!id || !author || !body || createdAt === null) { + return null; + } + + const message = { ...value } as TimelineMessage; + message.id = id; + message.author = author; + message.body = body; + message.createdAt = createdAt; + message.depth = + typeof value.depth === "number" && Number.isFinite(value.depth) + ? value.depth + : 0; + message.time = maybeString(value.time) ?? ""; + message.pubkey = maybeString(value.pubkey); + message.parentId = maybeNullableString(value.parentId); + message.rootId = maybeNullableString(value.rootId); + message.avatarUrl = maybeNullableString(value.avatarUrl); + message.renderKey = maybeString(value.renderKey); + + return message; +} + +function parseStoredAgentConversation( + value: unknown, +): AgentConversation | null { + if (!isRecord(value)) { + return null; + } + + const id = maybeString(value.id); + const agentName = maybeString(value.agentName); + const agentPubkey = maybeString(value.agentPubkey); + const channelId = maybeString(value.channelId); + const channelName = maybeString(value.channelName); + const threadRootId = maybeString(value.threadRootId); + const title = maybeString(value.title); + const titleStatus = + value.titleStatus === "provisional" || value.titleStatus === "resolved" + ? value.titleStatus + : null; + const createdAt = + typeof value.createdAt === "number" && Number.isFinite(value.createdAt) + ? value.createdAt + : null; + const agentReply = parseStoredTimelineMessage(value.agentReply); + const contextMessages = Array.isArray(value.contextMessages) + ? value.contextMessages + .map(parseStoredTimelineMessage) + .filter((message): message is TimelineMessage => message !== null) + : []; + const parentMessage = + value.parentMessage == null + ? null + : parseStoredTimelineMessage(value.parentMessage); + const threadRootMessage = + value.threadRootMessage == null + ? null + : parseStoredTimelineMessage(value.threadRootMessage); + + if ( + !id || + !agentName || + !agentPubkey || + !agentReply || + !channelId || + !channelName || + createdAt === null || + !threadRootId || + !title || + !titleStatus + ) { + return null; + } + + return { + id, + agentName, + agentPubkey, + agentReply, + channelId, + channelName, + contextMessages, + createdAt, + parentMessage, + threadRootId, + threadRootMessage, + title, + titleStatus, + }; +} + +export function readPersistedAgentConversations( + pubkey: string, +): AgentConversation[] { + try { + const raw = window.localStorage.getItem( + agentConversationsStorageKey(pubkey), + ); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + const byId = new Map(); + for (const value of parsed) { + const conversation = parseStoredAgentConversation(value); + if (conversation) { + byId.set(conversation.id, conversation); + } + } + + return [...byId.values()] + .sort((left, right) => right.createdAt - left.createdAt) + .slice(0, MAX_PERSISTED_AGENT_CONVERSATIONS); + } catch { + return []; + } +} + +export function writePersistedAgentConversations( + pubkey: string, + conversations: readonly AgentConversation[], +): void { + try { + const byId = new Map(); + for (const conversation of conversations) { + byId.set(conversation.id, conversation); + } + + const persisted = [...byId.values()] + .sort((left, right) => right.createdAt - left.createdAt) + .slice(0, MAX_PERSISTED_AGENT_CONVERSATIONS); + window.localStorage.setItem( + agentConversationsStorageKey(pubkey), + JSON.stringify(persisted), + ); + } catch { + // Best-effort local preference; ignore storage failures. + } +} + +export function buildAgentConversation( + input: OpenAgentConversationInput, +): AgentConversation { + const threadRootId = + input.threadRootMessage?.id ?? + input.agentReply.rootId ?? + input.agentReply.parentId ?? + input.agentReply.id; + const contextMessages = collectConversationContextMessages( + input, + threadRootId, + ); + const { status: titleStatus, title } = deriveTitleFromContext({ + agentPubkey: input.agentPubkey, + agentReply: input.agentReply, + contextMessages, + parentMessage: input.parentMessage, + threadRootId, + threadRootMessage: input.threadRootMessage, + }); + + return { + id: `${input.channel.id}:${input.agentPubkey}:${input.agentReply.id}`, + agentName: input.agentName, + agentPubkey: input.agentPubkey, + agentReply: input.agentReply, + channelId: input.channel.id, + channelName: input.channel.name, + contextMessages, + createdAt: Math.max( + input.agentReply.createdAt, + input.threadRootMessage?.createdAt ?? 0, + input.parentMessage?.createdAt ?? 0, + ...contextMessages.map((message) => message.createdAt), + ), + parentMessage: input.parentMessage, + threadRootId, + threadRootMessage: input.threadRootMessage, + title, + titleStatus, + }; +} + +function getTagValue(tags: string[][], name: string): string | null { + return tags.find((tag) => tag[0] === name)?.[1] ?? null; +} + +function getMarkedEventId(tags: string[][], marker: string): string | null { + return ( + tags.find( + (tag) => + tag[0] === "e" && + typeof tag[1] === "string" && + tag[1].length > 0 && + tag[3] === marker, + )?.[1] ?? null + ); +} + +function parseMarkerContent(content: string): Record { + try { + const parsed = JSON.parse(content); + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function trimmedString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function parseAgentConversationMarker( + event: RelayEvent, +): AgentConversationMarker | null { + if ( + event.kind !== KIND_AGENT_CONVERSATION && + event.kind !== KIND_AGENT_CONVERSATION_COMPAT + ) { + return null; + } + + const content = parseMarkerContent(event.content); + const channelId = getTagValue(event.tags, "h"); + const threadRootId = + getMarkedEventId(event.tags, "root") ?? + (typeof content.threadRootId === "string" ? content.threadRootId : null); + const agentReplyId = + getMarkedEventId(event.tags, "agent-reply") ?? + (typeof content.agentReplyId === "string" ? content.agentReplyId : null); + const agentPubkey = + getTagValue(event.tags, "p") ?? + (typeof content.agentPubkey === "string" ? content.agentPubkey : null); + const parentMessageId = + typeof content.parentMessageId === "string" + ? content.parentMessageId + : null; + const threadRootMessageId = + typeof content.threadRootMessageId === "string" + ? content.threadRootMessageId + : null; + const agentName = trimmedString(content.agentName) || agentPubkey || "Agent"; + const title = + trimmedString(content.title) ?? + getTagValue(event.tags, "title") ?? + "New conversation"; + const titleStatus = + content.titleStatus === "provisional" ? "provisional" : "resolved"; + const summary = trimmedString(content.summary); + const summaryCreatedAt = + typeof content.summaryCreatedAt === "number" && + Number.isFinite(content.summaryCreatedAt) + ? content.summaryCreatedAt + : null; + const startedAt = + typeof content.startedAt === "number" && Number.isFinite(content.startedAt) + ? content.startedAt + : event.created_at; + + if (!channelId || !threadRootId || !agentReplyId || !agentPubkey) { + return null; + } + + return { + agentName, + agentPubkey, + agentReplyId, + channelId, + createdAt: event.created_at, + eventId: event.id, + parentMessageId, + startedAt, + starterPubkey: event.pubkey, + summary, + summaryAuthorName: trimmedString(content.summaryAuthorName), + summaryAuthorPubkey: trimmedString(content.summaryAuthorPubkey), + summaryCreatedAt, + threadRootMessageId, + threadRootId, + title, + titleStatus, + }; +} + +export function buildAgentConversationMarkers( + events: readonly RelayEvent[], +): AgentConversationMarker[] { + const byAgentReplyId = new Map(); + + for (const event of events) { + const marker = parseAgentConversationMarker(event); + if (!marker) { + continue; + } + + const current = byAgentReplyId.get(marker.agentReplyId); + if ( + !current || + marker.createdAt > current.createdAt || + (marker.createdAt === current.createdAt && + marker.eventId > current.eventId) + ) { + byAgentReplyId.set(marker.agentReplyId, { + ...marker, + startedAt: Math.min( + current?.startedAt ?? marker.startedAt, + marker.startedAt, + ), + summary: marker.summary ?? current?.summary ?? null, + summaryAuthorName: + marker.summary != null + ? marker.summaryAuthorName + : (current?.summaryAuthorName ?? null), + summaryAuthorPubkey: + marker.summary != null + ? marker.summaryAuthorPubkey + : (current?.summaryAuthorPubkey ?? null), + summaryCreatedAt: + marker.summary != null + ? marker.summaryCreatedAt + : (current?.summaryCreatedAt ?? null), + }); + } else if (marker.startedAt < current.startedAt) { + byAgentReplyId.set(marker.agentReplyId, { + ...current, + startedAt: marker.startedAt, + summary: current.summary ?? marker.summary, + summaryAuthorName: current.summary + ? current.summaryAuthorName + : marker.summaryAuthorName, + summaryAuthorPubkey: current.summary + ? current.summaryAuthorPubkey + : marker.summaryAuthorPubkey, + summaryCreatedAt: current.summary + ? current.summaryCreatedAt + : marker.summaryCreatedAt, + }); + } else if (current.summary == null && marker.summary != null) { + byAgentReplyId.set(marker.agentReplyId, { + ...current, + summary: marker.summary, + summaryAuthorName: marker.summaryAuthorName, + summaryAuthorPubkey: marker.summaryAuthorPubkey, + summaryCreatedAt: marker.summaryCreatedAt, + }); + } + } + + return [...byAgentReplyId.values()].sort( + (left, right) => right.createdAt - left.createdAt, + ); +} + +export async function publishAgentConversationMarker( + input: OpenAgentConversationInput, + update: AgentConversationMarkerUpdate = {}, +): Promise { + const conversation = buildAgentConversation(input); + const startedAt = Math.floor(Date.now() / 1_000); + const parentMessageId = input.parentMessage?.id ?? null; + const threadRootMessageId = input.threadRootMessage?.id ?? null; + const summary = update.summary?.trim() || null; + const summaryAuthorName = update.summaryAuthorName?.trim() || null; + const summaryAuthorPubkey = update.summaryAuthorPubkey?.trim() || null; + const content = JSON.stringify({ + version: 1, + title: conversation.title, + titleStatus: conversation.titleStatus, + agentName: conversation.agentName, + agentPubkey: conversation.agentPubkey, + startedAt, + threadRootId: conversation.threadRootId, + threadRootMessageId, + parentMessageId, + agentReplyId: conversation.agentReply.id, + ...(summary + ? { + summary, + summaryAuthorName, + summaryAuthorPubkey, + summaryCreatedAt: update.summaryCreatedAt ?? null, + } + : {}), + }); + const event = await signRelayEvent({ + kind: KIND_AGENT_CONVERSATION_COMPAT, + content, + tags: [ + ["h", conversation.channelId], + ["e", conversation.threadRootId, "", "root"], + ["e", conversation.agentReply.id, "", "agent-reply"], + ["p", conversation.agentPubkey], + ["title", conversation.title], + ], + }); + + return relayClient.publishEvent( + event, + "Timed out opening the agent conversation.", + "Failed to open the agent conversation.", + ); +} + +export function getHiddenAgentConversationMessageIds( + messages: readonly TimelineMessage[], + markers: readonly AgentConversationMarker[] | undefined, +): Set { + if (!markers?.length || messages.length === 0) { + return new Set(); + } + + const orderedMessages = messages + .map((message, originalIndex) => ({ message, originalIndex })) + .sort( + (left, right) => + left.message.createdAt - right.message.createdAt || + left.originalIndex - right.originalIndex, + ); + const messageIndexById = new Map( + orderedMessages.map(({ message }, index) => [message.id, index]), + ); + const cutoffByThreadRootId = new Map< + string, + { + anchorIndex: number | null; + anchorMessageId: string; + startedAt: number; + } + >(); + for (const marker of markers) { + const current = cutoffByThreadRootId.get(marker.threadRootId); + const candidate = { + anchorIndex: messageIndexById.get(marker.agentReplyId) ?? null, + anchorMessageId: marker.agentReplyId, + startedAt: marker.startedAt, + }; + const isEarlier = + current === undefined || + (candidate.anchorIndex !== null && current.anchorIndex !== null + ? candidate.anchorIndex < current.anchorIndex + : candidate.startedAt < current.startedAt); + if (isEarlier) { + cutoffByThreadRootId.set(marker.threadRootId, candidate); + } + } + + const hiddenIds = new Set(); + for (const { message } of orderedMessages) { + const threadRootId = message.rootId ?? message.parentId ?? null; + if (!threadRootId || message.id === threadRootId) { + continue; + } + + const cutoff = cutoffByThreadRootId.get(threadRootId); + if (cutoff === undefined || message.id === cutoff.anchorMessageId) { + continue; + } + + const messageIndex = messageIndexById.get(message.id); + if (cutoff.anchorIndex !== null && messageIndex !== undefined) { + if (messageIndex > cutoff.anchorIndex) { + hiddenIds.add(message.id); + } + continue; + } + + if (message.createdAt > cutoff.startedAt) { + hiddenIds.add(message.id); + } + } + + return hiddenIds; +} diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts b/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts new file mode 100644 index 000000000..dba599794 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts @@ -0,0 +1,276 @@ +import type { AgentConversation } from "@/features/agents/agentConversations"; +import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages"; +import type { + TimelineMessage, + TimelineReaction, +} from "@/features/messages/types"; +import type { ManagedAgent, RelayAgent, RelayEvent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +const AGENT_STATUS_REACTION_EMOJIS = new Set(["👀", "💬"]); +const AGENT_PARTICIPANT_PREVIEW_LIMIT = 3; + +export type AgentConversationParticipant = { + avatarUrl: string | null; + canMessage: boolean; + displayName: string; + pubkey: string; +}; + +type KnownAgentParticipant = { + canMessage: boolean; + displayName: string; + pubkey: string; +}; + +export function uniqueMessages(messages: TimelineMessage[]) { + const byId = new Map(); + for (const message of messages) { + byId.set(message.id, message); + } + return [...byId.values()].sort((a, b) => a.createdAt - b.createdAt); +} + +export function flattenConversationMessages(messages: TimelineMessage[]) { + return messages.map((message) => ({ + ...message, + depth: 0, + parentId: null, + rootId: null, + })); +} + +function getAgentParticipantPreview( + participants: readonly AgentConversationParticipant[], +) { + const visibleParticipants = participants.slice( + 0, + AGENT_PARTICIPANT_PREVIEW_LIMIT, + ); + + return { + hiddenCount: Math.max( + 0, + participants.length - AGENT_PARTICIPANT_PREVIEW_LIMIT, + ), + visibleParticipants, + }; +} + +export function formatAgentParticipantNames( + participants: readonly AgentConversationParticipant[], +) { + const { hiddenCount, visibleParticipants } = + getAgentParticipantPreview(participants); + const names = visibleParticipants.map( + (participant) => participant.displayName, + ); + + return hiddenCount > 0 + ? [...names, `+${hiddenCount} more`].join(", ") + : names.join(", "); +} + +export function isConversationMessage( + message: TimelineMessage, + conversation: AgentConversation, +) { + return ( + message.id === conversation.threadRootId || + message.id === conversation.agentReply.id || + message.rootId === conversation.threadRootId || + message.parentId === conversation.threadRootId + ); +} + +export function formatAgentMentionList(names: readonly string[]) { + const mentions = names.map((name) => `@${name}`); + + if (mentions.length === 0) { + return "this agent"; + } + + if (mentions.length === 1) { + return mentions[0]; + } + + if (mentions.length === 2) { + return `${mentions[0]} and ${mentions[1]}`; + } + + return `${mentions.slice(0, -1).join(", ")}, and ${ + mentions[mentions.length - 1] + }`; +} + +export function getLatestRelayMessageEvent(events: RelayEvent[]) { + return events.reduce((latest, event) => { + if (!latest || event.created_at > latest.created_at) { + return event; + } + + return latest; + }, null); +} + +function stripAgentStatusReactionUsers( + reaction: TimelineReaction, + agentPubkeys: ReadonlySet, +): TimelineReaction | null { + if (!AGENT_STATUS_REACTION_EMOJIS.has(reaction.emoji)) { + return reaction; + } + + const remainingUsers = reaction.users.filter( + (user) => !agentPubkeys.has(normalizePubkey(user.pubkey)), + ); + const removedCount = reaction.users.length - remainingUsers.length; + if (removedCount <= 0) { + return reaction; + } + + const nextCount = Math.max(0, reaction.count - removedCount); + if (nextCount === 0) { + return null; + } + + return { + ...reaction, + count: nextCount, + users: remainingUsers, + }; +} + +export function stripAgentStatusReactions( + message: TimelineMessage, + agentPubkeys: ReadonlySet, +) { + if (!message.reactions?.length || agentPubkeys.size === 0) { + return message; + } + + let didChange = false; + const reactions = message.reactions + .map((reaction) => { + const nextReaction = stripAgentStatusReactionUsers( + reaction, + agentPubkeys, + ); + if (nextReaction !== reaction) { + didChange = true; + } + return nextReaction; + }) + .filter((reaction): reaction is TimelineReaction => reaction !== null); + + if (!didChange) { + return message; + } + + return { + ...message, + reactions: reactions.length > 0 ? reactions : undefined, + }; +} + +function isRelayAgentMessageable(agent: RelayAgent) { + return agent.respondTo === "anyone"; +} + +export function normalizeRecapTextForComparison( + value: string | null | undefined, +) { + return (value ?? "").replace(/\s+/g, " ").trim().toLocaleLowerCase(); +} + +export function buildKnownAgentParticipants({ + conversation, + managedAgents, + relayAgents, +}: { + conversation: AgentConversation; + managedAgents: ManagedAgent[] | undefined; + relayAgents: RelayAgent[] | undefined; +}) { + const participants = new Map(); + const add = (participant: KnownAgentParticipant) => { + const normalized = normalizePubkey(participant.pubkey); + if (!normalized) { + return; + } + + const current = participants.get(normalized); + participants.set(normalized, { + canMessage: current?.canMessage || participant.canMessage, + displayName: + current?.displayName && current.displayName !== current.pubkey + ? current.displayName + : participant.displayName, + pubkey: current?.pubkey ?? participant.pubkey, + }); + }; + + for (const agent of managedAgents ?? []) { + add({ + canMessage: true, + displayName: agent.name, + pubkey: agent.pubkey, + }); + } + + for (const agent of relayAgents ?? []) { + add({ + canMessage: isRelayAgentMessageable(agent), + displayName: agent.name, + pubkey: agent.pubkey, + }); + } + + if (!participants.has(normalizePubkey(conversation.agentPubkey))) { + add({ + canMessage: true, + displayName: conversation.agentName, + pubkey: conversation.agentPubkey, + }); + } + + return participants; +} + +export function getKnownAgentPubkeysInMessages( + messages: readonly TimelineMessage[], + knownAgents: ReadonlyMap, +) { + const pubkeys: string[] = []; + const add = (pubkey: string | null | undefined) => { + if (!pubkey) { + return; + } + + const normalized = normalizePubkey(pubkey); + if ( + normalized && + knownAgents.has(normalized) && + !pubkeys.some((current) => normalizePubkey(current) === normalized) + ) { + pubkeys.push(knownAgents.get(normalized)?.pubkey ?? pubkey); + } + }; + + for (const message of messages) { + add(message.pubkey); + } + for (const pubkey of collectMessageMentionPubkeys([...messages])) { + add(pubkey); + } + + return pubkeys; +} + +export function collectTimelineMessageAuthorPubkeys( + messages: readonly TimelineMessage[], +) { + return messages + .map((message) => message.pubkey) + .filter((pubkey): pubkey is string => Boolean(pubkey)); +} diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.tsx b/desktop/src/features/agents/ui/AgentConversationScreen.tsx new file mode 100644 index 000000000..d9bffae54 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentConversationScreen.tsx @@ -0,0 +1,791 @@ +import * as React from "react"; +import { ArrowLeft, Bot, createLucideIcon } from "lucide-react"; +import { toast } from "sonner"; + +import { + buildAgentConversationMentionPubkeys, + buildAgentConversationMarkers, + buildAgentConversationRecap, + deriveAgentConversationTitle, + getAutoRoutedAgentConversationPubkeys, + type AgentConversation, + publishAgentConversationMarker, +} from "@/features/agents/agentConversations"; +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { useAppShell } from "@/app/AppShellContext"; +import { ChatHeader } from "@/features/chat/ui/ChatHeader"; +import { + useChannelMessagesQuery, + useChannelSubscription, + useSendMessageMutation, +} from "@/features/messages/hooks"; +import { + collectMessageAuthorPubkeys, + collectMessageMentionPubkeys, + formatTimelineMessages, +} from "@/features/messages/lib/formatTimelineMessages"; +import { + buildKnownAgentParticipants, + collectTimelineMessageAuthorPubkeys, + flattenConversationMessages, + formatAgentMentionList, + formatAgentParticipantNames, + getKnownAgentPubkeysInMessages, + getLatestRelayMessageEvent, + isConversationMessage, + normalizeRecapTextForComparison, + stripAgentStatusReactions, + uniqueMessages, + type AgentConversationParticipant, +} from "./AgentConversationScreen.helpers"; +import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; +import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; +import { useChannelTyping } from "@/features/messages/useChannelTyping"; +import { + MessageAuthorText, + MessageHeaderRow, +} from "@/features/messages/ui/MessageHeader"; +import { MessageComposer } from "@/features/messages/ui/MessageComposer"; +import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { Channel, Identity, Profile } from "@/shared/api/types"; +import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout"; +import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Shimmer } from "@/shared/ui/Shimmer"; +import { Button } from "@/shared/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +const Summary = createLucideIcon("Summary", [ + ["path", { d: "M15 4H7", key: "summary-heading" }], + ["path", { d: "m18 16 3 3-3 3", key: "summary-arrow" }], + ["path", { d: "M3 4v13a2 2 0 0 0 2 2h16", key: "summary-page" }], + ["path", { d: "M7 14h7", key: "summary-line-short" }], + ["path", { d: "M7 9h12", key: "summary-line-long" }], +]); + +type AgentConversationScreenProps = { + channel: Channel | null; + conversation: AgentConversation; + currentIdentity?: Identity; + currentProfile?: Profile; + onBackToThread?: (conversation: AgentConversation) => void; +}; + +function AgentThinkingRow({ + agentName, + avatarUrl, +}: { + agentName: string; + avatarUrl: string | null; +}) { + return ( +
+ +
+ + {agentName} + +

+ Thinking... +

+
+
+ ); +} + +export function AgentConversationScreen({ + channel, + conversation, + currentIdentity, + currentProfile, + onBackToThread, +}: AgentConversationScreenProps) { + const screenRef = React.useRef(null); + const timelineScrollRef = React.useRef(null); + const composerWrapperRef = React.useRef(null); + const media = useMediaUpload(); + const messagesQuery = useChannelMessagesQuery(channel); + const managedAgentsQuery = useManagedAgentsQuery(); + const relayAgentsQuery = useRelayAgentsQuery(); + useChannelSubscription(channel); + const sendMessageMutation = useSendMessageMutation(channel, currentIdentity); + + const relayMessages = messagesQuery.data ?? []; + const { + getMessageReadAt, + isThreadMuted, + markThreadRead, + markMessageRead, + updateAgentConversationTitle, + } = useAppShell(); + const latestMessageEvent = React.useMemo( + () => getLatestRelayMessageEvent(relayMessages), + [relayMessages], + ); + const typingEntries = useChannelTyping( + channel, + currentIdentity?.pubkey, + latestMessageEvent, + ); + const knownAgentParticipants = React.useMemo( + () => + buildKnownAgentParticipants({ + conversation, + managedAgents: managedAgentsQuery.data, + relayAgents: relayAgentsQuery.data, + }), + [conversation, managedAgentsQuery.data, relayAgentsQuery.data], + ); + const profilePubkeys = React.useMemo( + () => + [ + ...new Set([ + ...collectMessageAuthorPubkeys(relayMessages), + ...collectMessageMentionPubkeys(relayMessages), + ...collectTimelineMessageAuthorPubkeys(conversation.contextMessages), + ...collectMessageMentionPubkeys([...conversation.contextMessages]), + ...typingEntries.map((entry) => entry.pubkey), + conversation.agentPubkey, + currentIdentity?.pubkey ?? "", + ]), + ].filter(Boolean), + [ + conversation.agentPubkey, + conversation.contextMessages, + currentIdentity?.pubkey, + relayMessages, + typingEntries, + ], + ); + const profilesQuery = useUsersBatchQuery(profilePubkeys, { + enabled: profilePubkeys.length > 0, + }); + const profiles = React.useMemo( + () => + mergeCurrentProfileIntoLookup( + profilesQuery.data?.profiles, + currentProfile, + ) ?? {}, + [currentProfile, profilesQuery.data?.profiles], + ); + + const knownAgentPubkeys = React.useMemo( + () => new Set(knownAgentParticipants.keys()), + [knownAgentParticipants], + ); + const conversationSourceMessages = React.useMemo(() => { + if (!channel || relayMessages.length === 0) { + return uniqueMessages( + conversation.contextMessages.length > 0 + ? conversation.contextMessages + : ([ + conversation.threadRootMessage, + conversation.parentMessage, + conversation.agentReply, + ].filter(Boolean) as TimelineMessage[]), + ).map((message) => stripAgentStatusReactions(message, knownAgentPubkeys)); + } + + const formatted = formatTimelineMessages( + relayMessages, + channel, + currentIdentity?.pubkey, + currentProfile?.avatarUrl ?? null, + profiles, + ); + const scoped = formatted.filter((message) => + isConversationMessage(message, conversation), + ); + const sourceMessages = + scoped.length > 0 + ? scoped + : uniqueMessages( + conversation.contextMessages.length > 0 + ? conversation.contextMessages + : ([ + conversation.threadRootMessage, + conversation.parentMessage, + conversation.agentReply, + ].filter(Boolean) as TimelineMessage[]), + ); + + return sourceMessages.map((message) => + stripAgentStatusReactions(message, knownAgentPubkeys), + ); + }, [ + channel, + conversation, + currentIdentity?.pubkey, + currentProfile?.avatarUrl, + knownAgentPubkeys, + profiles, + relayMessages, + ]); + const timelineMessages = React.useMemo( + () => flattenConversationMessages(conversationSourceMessages), + [conversationSourceMessages], + ); + + const conversationAgentPubkeys = React.useMemo(() => { + const pubkeys = getKnownAgentPubkeysInMessages( + conversationSourceMessages, + knownAgentParticipants, + ); + if ( + !pubkeys.some( + (pubkey) => + normalizePubkey(pubkey) === normalizePubkey(conversation.agentPubkey), + ) + ) { + pubkeys.unshift(conversation.agentPubkey); + } + + return pubkeys; + }, [ + conversation.agentPubkey, + conversationSourceMessages, + knownAgentParticipants, + ]); + const agentPubkeys = React.useMemo( + () => + new Set( + conversationAgentPubkeys.map((pubkey) => normalizePubkey(pubkey)), + ), + [conversationAgentPubkeys], + ); + const typingAgentPubkeys = React.useMemo(() => { + const latestMessage = timelineMessages[timelineMessages.length - 1] ?? null; + const latestMessagePubkey = latestMessage?.pubkey + ? normalizePubkey(latestMessage.pubkey) + : null; + const pubkeys: string[] = []; + for (const entry of typingEntries) { + const normalized = normalizePubkey(entry.pubkey); + if ( + entry.threadHeadId !== conversation.threadRootId || + !agentPubkeys.has(normalized) || + latestMessagePubkey === normalized || + pubkeys.some((pubkey) => normalizePubkey(pubkey) === normalized) + ) { + continue; + } + + pubkeys.push( + knownAgentParticipants.get(normalized)?.pubkey ?? entry.pubkey, + ); + } + + return pubkeys; + }, [ + conversation.threadRootId, + agentPubkeys, + knownAgentParticipants, + timelineMessages, + typingEntries, + ]); + const agentParticipants = React.useMemo( + () => + conversationAgentPubkeys.map((pubkey) => { + const normalized = normalizePubkey(pubkey); + const knownAgent = knownAgentParticipants.get(normalized); + const profile = profiles[normalized]; + + return { + avatarUrl: profile?.avatarUrl ?? null, + canMessage: knownAgent?.canMessage ?? true, + displayName: + profile?.displayName?.trim() || + knownAgent?.displayName || + (normalized === normalizePubkey(conversation.agentPubkey) + ? conversation.agentName + : pubkey), + pubkey: knownAgent?.pubkey ?? pubkey, + }; + }), + [ + conversation.agentName, + conversation.agentPubkey, + conversationAgentPubkeys, + knownAgentParticipants, + profiles, + ], + ); + const typingAgentParticipants = React.useMemo( + () => + typingAgentPubkeys + .map((pubkey) => { + const normalized = normalizePubkey(pubkey); + return agentParticipants.find( + (participant) => normalizePubkey(participant.pubkey) === normalized, + ); + }) + .filter( + (participant): participant is AgentConversationParticipant => + participant != null, + ), + [agentParticipants, typingAgentPubkeys], + ); + const participantSubtitle = React.useMemo( + () => formatAgentParticipantNames(agentParticipants), + [agentParticipants], + ); + const lastTitlePublishKeyRef = React.useRef(null); + React.useEffect(() => { + const threadRootMessage = + conversationSourceMessages.find( + (message) => message.id === conversation.threadRootId, + ) ?? + conversation.threadRootMessage ?? + null; + const parentMessage = + conversation.agentReply.parentId != null + ? (conversationSourceMessages.find( + (message) => message.id === conversation.agentReply.parentId, + ) ?? + conversation.parentMessage ?? + null) + : (conversation.parentMessage ?? null); + const derivedTitle = deriveAgentConversationTitle({ + agentPubkey: conversation.agentPubkey, + agentReply: conversation.agentReply, + contextMessages: conversationSourceMessages, + parentMessage, + threadRootId: conversation.threadRootId, + threadRootMessage, + }); + + if (derivedTitle.status !== "resolved") { + return; + } + if ( + conversation.titleStatus === derivedTitle.status && + conversation.title === derivedTitle.title + ) { + return; + } + + const latestContextMessage = + conversationSourceMessages[conversationSourceMessages.length - 1] ?? null; + const publishKey = `${conversation.id}:${derivedTitle.status}:${derivedTitle.title}:${latestContextMessage?.id ?? "none"}`; + if (lastTitlePublishKeyRef.current === publishKey) { + return; + } + lastTitlePublishKeyRef.current = publishKey; + + updateAgentConversationTitle( + conversation.id, + derivedTitle.title, + derivedTitle.status, + ); + void publishAgentConversationMarker({ + agentName: conversation.agentName, + agentPubkey: conversation.agentPubkey, + agentReply: conversation.agentReply, + channel: { + id: conversation.channelId, + name: conversation.channelName, + }, + contextMessages: conversationSourceMessages, + parentMessage, + threadRootMessage, + }).catch((error) => { + console.warn("[agentConversations] title marker publish failed:", error); + }); + }, [conversation, conversationSourceMessages, updateAgentConversationTitle]); + React.useEffect(() => { + const latestMessage = timelineMessages[timelineMessages.length - 1] ?? null; + if (latestMessage) { + markThreadRead(conversation.threadRootId, latestMessage.createdAt); + } + + if (isThreadMuted(conversation.threadRootId)) { + return; + } + + for (const message of timelineMessages) { + const readAt = getMessageReadAt(message.id); + if (readAt === null || readAt < message.createdAt) { + markMessageRead(message.id, message.createdAt); + } + } + }, [ + conversation.threadRootId, + getMessageReadAt, + isThreadMuted, + markMessageRead, + markThreadRead, + timelineMessages, + ]); + const routeableAgentPubkeys = React.useMemo( + () => + agentParticipants + .filter((participant) => participant.canMessage) + .map((participant) => participant.pubkey), + [agentParticipants], + ); + const autoRoutedAgentPubkeys = React.useMemo( + () => getAutoRoutedAgentConversationPubkeys(agentParticipants), + [agentParticipants], + ); + const canMessageAnyAgent = routeableAgentPubkeys.length > 0; + const restrictedAgentNames = React.useMemo( + () => + agentParticipants + .filter((participant) => !participant.canMessage) + .map((participant) => participant.displayName), + [agentParticipants], + ); + const restrictedAgentLabel = React.useMemo( + () => formatAgentMentionList(restrictedAgentNames), + [restrictedAgentNames], + ); + const composerPlaceholder = React.useMemo(() => { + if (!canMessageAnyAgent) { + return "Reply to conversation"; + } + if (agentParticipants.length === 1) { + return `Message ${agentParticipants[0]?.displayName ?? "agent"}`; + } + + return "Message conversation"; + }, [agentParticipants, canMessageAnyAgent]); + const emptyDescription = + agentParticipants.length === 1 + ? "Send a message below to keep working with this agent on the topic." + : "Send a message below to keep working with these agents on the topic."; + const [isPublishingThreadSummary, setIsPublishingThreadSummary] = + React.useState(false); + const lastPublishedThreadRecapRef = React.useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: reset the cached recap when switching conversations. + React.useEffect(() => { + lastPublishedThreadRecapRef.current = null; + }, [conversation.id]); + const agentConversationMarkers = React.useMemo( + () => buildAgentConversationMarkers(relayMessages), + [relayMessages], + ); + const currentConversationMarker = React.useMemo( + () => + agentConversationMarkers.find( + (marker) => + marker.channelId === conversation.channelId && + marker.agentReplyId === conversation.agentReply.id, + ) ?? null, + [ + agentConversationMarkers, + conversation.agentReply.id, + conversation.channelId, + ], + ); + const generatedThreadRecap = React.useMemo( + () => + buildAgentConversationRecap({ + agentPubkeys, + conversationTitle: conversation.title, + messages: timelineMessages, + }), + [agentPubkeys, conversation.title, timelineMessages], + ); + const primaryRecapAgent = agentParticipants[0] ?? null; + const latestPublishedRecap = + currentConversationMarker?.summary ?? + lastPublishedThreadRecapRef.current ?? + null; + const headerChromeRef = useMeasuredCssVariable({ + targetRef: screenRef, + ...channelContentTopPaddingMeasurement, + resetKey: conversation.id, + }); + useComposerHeightPadding( + timelineScrollRef, + composerWrapperRef, + conversation.id, + 16, + ); + + const handleSend = React.useCallback( + async ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => { + await sendMessageMutation.mutateAsync({ + content, + mediaTags, + mentionPubkeys: buildAgentConversationMentionPubkeys({ + autoRouteAgentPubkeys: autoRoutedAgentPubkeys, + mentionPubkeys, + }), + parentEventId: conversation.threadRootId, + }); + }, + [autoRoutedAgentPubkeys, conversation.threadRootId, sendMessageMutation], + ); + + const isComposerDisabled = + !channel?.isMember || + channel.archivedAt !== null || + sendMessageMutation.isPending; + const canSendThreadSummary = + Boolean(channel?.isMember) && + channel?.archivedAt === null && + !isPublishingThreadSummary && + generatedThreadRecap !== null; + const markerThreadRootMessage = React.useMemo( + () => + conversationSourceMessages.find( + (message) => message.id === conversation.threadRootId, + ) ?? + conversation.threadRootMessage ?? + null, + [ + conversation.threadRootId, + conversation.threadRootMessage, + conversationSourceMessages, + ], + ); + const markerParentMessage = React.useMemo(() => { + if (conversation.agentReply.parentId == null) { + return conversation.parentMessage ?? null; + } + + return ( + conversationSourceMessages.find( + (message) => message.id === conversation.agentReply.parentId, + ) ?? + conversation.parentMessage ?? + null + ); + }, [ + conversation.agentReply.parentId, + conversation.parentMessage, + conversationSourceMessages, + ]); + const handleSendSummaryToThread = React.useCallback(async () => { + if (!canSendThreadSummary || !generatedThreadRecap) { + return; + } + + const nextRecap = generatedThreadRecap.trim(); + if ( + normalizeRecapTextForComparison(nextRecap) === + normalizeRecapTextForComparison(latestPublishedRecap) + ) { + toast.info("Recap is already up to date"); + return; + } + + setIsPublishingThreadSummary(true); + try { + await publishAgentConversationMarker( + { + agentName: conversation.agentName, + agentPubkey: conversation.agentPubkey, + agentReply: conversation.agentReply, + channel: { + id: conversation.channelId, + name: conversation.channelName, + }, + contextMessages: conversationSourceMessages, + parentMessage: markerParentMessage, + threadRootMessage: markerThreadRootMessage, + }, + { + summary: nextRecap, + summaryAuthorName: + primaryRecapAgent?.displayName ?? conversation.agentName, + summaryAuthorPubkey: + primaryRecapAgent?.pubkey ?? conversation.agentPubkey, + summaryCreatedAt: Math.floor(Date.now() / 1_000), + }, + ); + lastPublishedThreadRecapRef.current = nextRecap; + toast.success( + latestPublishedRecap + ? "Updated recap in thread" + : "Added recap to thread", + ); + } catch (error) { + console.error("[agentConversations] failed to publish recap:", error); + toast.error("Failed to add recap to thread"); + } finally { + setIsPublishingThreadSummary(false); + } + }, [ + canSendThreadSummary, + conversation.agentName, + conversation.agentPubkey, + conversation.agentReply, + conversation.channelId, + conversation.channelName, + conversationSourceMessages, + generatedThreadRecap, + latestPublishedRecap, + markerThreadRootMessage, + markerParentMessage, + primaryRecapAgent?.displayName, + primaryRecapAgent?.pubkey, + ]); + const headerActions = ( + + + + + + Add a conversation recap to the original thread + + + ); + const headerLeadingContent = onBackToThread ? ( + + + + + Back to source thread + + ) : ( + false + ); + + return ( +
+ + + , + }} + channelName={channel?.name ?? conversation.channelName} + channelType={channel?.channelType ?? "stream"} + contentTopPadding="chrome" + currentPubkey={currentIdentity?.pubkey} + emptyDescription={emptyDescription} + emptyTitle="No conversation messages yet" + hasComposerOverlay + isLoading={messagesQuery.isLoading && timelineMessages.length === 0} + messageListPlacement="top" + messages={timelineMessages} + profiles={profiles} + scrollContainerRef={timelineScrollRef} + showInitialDayDivider={false} + trailingContent={ + typingAgentParticipants.length > 0 + ? typingAgentParticipants.map((participant) => ( + + )) + : null + } + /> + +
+
+ +

+ You can view and reply to this conversation. +

+

+ You can't message{" "} + + {restrictedAgentLabel} + + . +

+
+ ) + } + profiles={profiles} + showTopBorder={false} + typingParentEventId={conversation.threadRootId} + typingRootEventId={conversation.threadRootId} + /> +
+
+
+
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs new file mode 100644 index 000000000..13dfc2bc9 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getDmAutoRouteAgentPubkeys, + getThreadAutoRouteAgentPubkeys, + mergeAutoRouteMentionPubkeys, +} from "./ChannelPane.helpers.ts"; + +function channel(overrides = {}) { + return { + id: "channel", + name: "Channel", + channelType: "stream", + visibility: "open", + description: "", + topic: null, + purpose: null, + memberCount: 2, + memberPubkeys: [], + lastMessageAt: null, + archivedAt: null, + participants: [], + participantPubkeys: [], + isMember: true, + ttlSeconds: null, + ttlDeadline: null, + ...overrides, + }; +} + +function message(overrides = {}) { + return { + id: "message", + createdAt: 1, + pubkey: "human", + author: "Human", + avatarUrl: null, + role: undefined, + personaDisplayName: undefined, + time: "1:00 PM", + body: "Body", + parentId: null, + rootId: null, + depth: 0, + accent: false, + pending: undefined, + edited: false, + kind: 9, + tags: [], + reactions: undefined, + ...overrides, + }; +} + +test("DM composer auto-routes only when exactly one other participant is an agent", () => { + const knownAgentPubkeys = new Set(["agent-one", "agent-two"]); + + assert.deepEqual( + getDmAutoRouteAgentPubkeys({ + channel: channel({ + channelType: "dm", + participantPubkeys: ["human", "agent-one"], + }), + currentPubkey: "human", + knownAgentPubkeys, + }), + ["agent-one"], + ); + + assert.deepEqual( + getDmAutoRouteAgentPubkeys({ + channel: channel({ + channelType: "dm", + participantPubkeys: ["human", "agent-one", "agent-two"], + }), + currentPubkey: "human", + knownAgentPubkeys, + }), + [], + ); + + assert.deepEqual( + getDmAutoRouteAgentPubkeys({ + channel: channel({ + participantPubkeys: ["human", "agent-one"], + }), + currentPubkey: "human", + knownAgentPubkeys, + }), + [], + ); +}); + +test("thread composer auto-routes only for one human and one known agent", () => { + const knownAgentPubkeys = new Set(["agent-one", "agent-two"]); + + assert.deepEqual( + getThreadAutoRouteAgentPubkeys({ + knownAgentPubkeys, + messages: [ + message({ id: "root", tags: [["p", "agent-one"]] }), + message({ id: "agent-reply", pubkey: "agent-one" }), + ], + }), + ["agent-one"], + ); + + assert.deepEqual( + getThreadAutoRouteAgentPubkeys({ + knownAgentPubkeys, + messages: [ + message({ + id: "root", + pubkey: "human-one", + tags: [ + ["p", "human-one"], + ["p", "agent-one"], + ], + }), + message({ + id: "human-two-reply", + pubkey: "human-two", + tags: [ + ["p", "human-two"], + ["p", "agent-one"], + ], + }), + message({ id: "agent-reply", pubkey: "agent-one" }), + ], + }), + [], + ); + + assert.deepEqual( + getThreadAutoRouteAgentPubkeys({ + knownAgentPubkeys, + messages: [ + message({ id: "agent-one-reply", pubkey: "agent-one" }), + message({ id: "agent-two-reply", pubkey: "agent-two" }), + ], + }), + [], + ); +}); + +test("auto-routed mentions merge with explicit mentions without duplicates", () => { + assert.deepEqual( + mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys: ["AGENT-ONE"], + mentionPubkeys: ["agent-one", "agent-two"], + }), + ["AGENT-ONE", "agent-two"], + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index 8aef5f523..929b39117 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -2,6 +2,8 @@ import { isEphemeralChannel } from "@/features/channels/lib/ephemeralChannel"; import type { TimelineMessage } from "@/features/messages/types"; import type { Channel } from "@/shared/api/types"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { getMentionTagPubkey } from "@/shared/lib/resolveMentionNames"; export function getChannelIntroKind(channel: Channel): string { const isPrivate = channel.visibility === "private"; @@ -51,3 +53,121 @@ export function mentionsKnownAgent( knownAgentPubkeys.has(pubkey.toLowerCase()), ); } + +function singleKnownAgentPubkey( + pubkeys: Iterable, + knownAgentPubkeys: ReadonlySet, +) { + const agentPubkeys = new Map(); + + for (const pubkey of pubkeys) { + if (!pubkey) { + continue; + } + + const normalized = normalizePubkey(pubkey); + if (!knownAgentPubkeys.has(normalized)) { + continue; + } + + agentPubkeys.set(normalized, pubkey); + } + + return agentPubkeys.size === 1 ? [...agentPubkeys.values()] : []; +} + +export function getDmAutoRouteAgentPubkeys({ + channel, + currentPubkey, + knownAgentPubkeys, +}: { + channel: Channel | null; + currentPubkey?: string; + knownAgentPubkeys: ReadonlySet; +}) { + if (channel?.channelType !== "dm") { + return []; + } + + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + + return singleKnownAgentPubkey( + channel.participantPubkeys.filter( + (pubkey) => + !normalizedCurrentPubkey || + normalizePubkey(pubkey) !== normalizedCurrentPubkey, + ), + knownAgentPubkeys, + ); +} + +export function getThreadAutoRouteAgentPubkeys({ + knownAgentPubkeys, + messages, +}: { + knownAgentPubkeys: ReadonlySet; + messages: readonly TimelineMessage[]; +}) { + const agentPubkeys = new Map(); + const humanPubkeys = new Set(); + const addParticipant = (pubkey: string | null | undefined) => { + if (!pubkey) { + return; + } + + const normalized = normalizePubkey(pubkey); + if (!normalized) { + return; + } + + if (knownAgentPubkeys.has(normalized)) { + agentPubkeys.set(normalized, pubkey); + return; + } + + humanPubkeys.add(normalized); + }; + + for (const message of messages) { + addParticipant(message.pubkey); + + for (const tag of message.tags ?? []) { + addParticipant(getMentionTagPubkey(tag)); + } + } + + return agentPubkeys.size === 1 && humanPubkeys.size === 1 + ? [...agentPubkeys.values()] + : []; +} + +export function mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys, + mentionPubkeys, +}: { + autoRouteAgentPubkeys: readonly string[]; + mentionPubkeys: readonly string[]; +}) { + const seenPubkeys = new Set(); + const merged: string[] = []; + const add = (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (!normalized || seenPubkeys.has(normalized)) { + return; + } + + seenPubkeys.add(normalized); + merged.push(pubkey); + }; + + for (const pubkey of autoRouteAgentPubkeys) { + add(pubkey); + } + for (const pubkey of mentionPubkeys) { + add(pubkey); + } + + return merged; +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index c0c5b19b0..8aa8e46b3 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -11,6 +11,10 @@ import { MessageTimeline, type MessageTimelineHandle, } from "@/features/messages/ui/MessageTimeline"; +import { + getHiddenAgentConversationMessageIds, + type AgentConversationMarker, +} from "@/features/agents/agentConversations"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay"; import { @@ -62,11 +66,14 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { isWelcomeChannel } from "@/features/onboarding/welcome"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import type { Channel } from "@/shared/api/types"; +import { useAppShell } from "@/app/AppShellContext"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; + type ChannelPaneProps = { activeChannel: Channel | null; + agentConversationMarkers?: readonly AgentConversationMarker[]; activityAgents?: BotActivityAgent[]; agentPubkeys?: ReadonlySet; agentSessionAgents: ChannelAgentSessionAgent[]; @@ -179,6 +186,7 @@ type ChannelPaneProps = { }; export const ChannelPane = React.memo(function ChannelPane({ activeChannel, + agentConversationMarkers, agentPubkeys, agentSessionAgents, activityAgents = agentSessionAgents, @@ -261,6 +269,7 @@ export const ChannelPane = React.memo(function ChannelPane({ const timelineScrollRef = React.useRef(null); const messageTimelineRef = React.useRef(null); const composerWrapperRef = React.useRef(null); + const { openAgentConversation } = useAppShell(); const completedWelcomeBannerChannelIdsRef = React.useRef(new Set()); const welcomeComposerDismissTimerRef = React.useRef(null); const welcomeComposerHideTimerRef = React.useRef(null); @@ -433,6 +442,47 @@ export const ChannelPane = React.memo(function ChannelPane({ onSendMessage, ], ); + const handleOpenAgentSession = React.useCallback( + (pubkey: string) => { + onOpenAgentSession(pubkey); + }, + [onOpenAgentSession], + ); + const handleOpenAgentConversation = React.useCallback( + (message: TimelineMessage, options?: { publishMarker?: boolean }) => { + if (!activeChannel || !message.pubkey) { + return; + } + + const rootId = message.rootId ?? message.parentId ?? message.id; + const contextMessages = messages.filter( + (candidate) => + candidate.id === rootId || + candidate.id === message.id || + candidate.rootId === rootId || + candidate.parentId === rootId, + ); + openAgentConversation( + { + agentName: message.author, + agentPubkey: message.pubkey, + agentReply: message, + channel: activeChannel, + contextMessages, + parentMessage: message.parentId + ? (messages.find( + (candidate) => candidate.id === message.parentId, + ) ?? null) + : null, + threadRootMessage: rootId + ? (messages.find((candidate) => candidate.id === rootId) ?? null) + : null, + }, + options, + ); + }, + [activeChannel, messages, openAgentConversation], + ); const canDropInMainColumn = hasMainComposerOverlay && !isComposerDisabled && !isSinglePanelView; const hasTypingActivity = typingPubkeys.length > 0; @@ -475,8 +525,29 @@ export const ChannelPane = React.memo(function ChannelPane({ } return pubkeys; }, [botTypingEntries, openThreadHeadId]); - const hasThreadComposerBotActivity = - threadComposerBotTypingPubkeys.length > 0; + const threadActivityAgents = React.useMemo(() => { + if ( + threadComposerBotTypingPubkeys.length === 0 || + (openThreadHeadId && + agentConversationMarkers?.some( + (marker) => marker.threadRootId === openThreadHeadId, + )) + ) { + return []; + } + + const threadTypingSet = new Set( + threadComposerBotTypingPubkeys.map((pubkey) => pubkey.toLowerCase()), + ); + return activityAgents.filter((agent) => + threadTypingSet.has(agent.pubkey.toLowerCase()), + ); + }, [ + activityAgents, + agentConversationMarkers, + openThreadHeadId, + threadComposerBotTypingPubkeys, + ]); const directMessageIntro = React.useMemo( () => buildDirectMessageIntro({ @@ -551,22 +622,76 @@ export const ChannelPane = React.memo(function ChannelPane({ }; }, [activeChannel, onAddAgent, onCreateChannel, onOpenMembers]); - const visibleMessages = React.useMemo(() => { + const baseVisibleMessages = React.useMemo(() => { if (!isWelcomeChannel(activeChannel)) { return messages; } return messages.filter((message) => !isWelcomeSetupSystemMessage(message)); }, [activeChannel, messages]); + const threadSourceMessages = React.useMemo(() => { + if (!threadHeadMessage && threadMessages.length === 0) { + return []; + } + + return [ + ...(threadHeadMessage ? [threadHeadMessage] : []), + ...threadMessages.map((entry) => entry.message), + ]; + }, [threadHeadMessage, threadMessages]); + const hiddenAgentConversationMessageIds = React.useMemo(() => { + const hiddenIds = getHiddenAgentConversationMessageIds( + baseVisibleMessages, + agentConversationMarkers, + ); + const threadHiddenIds = getHiddenAgentConversationMessageIds( + threadSourceMessages, + agentConversationMarkers, + ); + for (const id of threadHiddenIds) { + hiddenIds.add(id); + } + return hiddenIds; + }, [agentConversationMarkers, baseVisibleMessages, threadSourceMessages]); + const visibleMessages = React.useMemo(() => { + if (hiddenAgentConversationMessageIds.size === 0) { + return baseVisibleMessages; + } + + return baseVisibleMessages.filter( + (message) => !hiddenAgentConversationMessageIds.has(message.id), + ); + }, [baseVisibleMessages, hiddenAgentConversationMessageIds]); + const visibleThreadMessages = React.useMemo(() => { + if (hiddenAgentConversationMessageIds.size === 0) { + return threadMessages; + } + + return threadMessages.filter( + (entry) => !hiddenAgentConversationMessageIds.has(entry.message.id), + ); + }, [hiddenAgentConversationMessageIds, threadMessages]); const mainTimelineEntries = React.useMemo( () => buildMainTimelineEntries(visibleMessages), [visibleMessages], ); + const handleEditLastOwnThreadMessage = React.useCallback((): boolean => { + if (!onEdit) return false; + // Thread scope = the open thread head plus its visible replies, in + // chronological order. The head is oldest, so append it first. + const scope: TimelineMessage[] = []; + if (threadHeadMessage) scope.push(threadHeadMessage); + for (const entry of visibleThreadMessages) scope.push(entry.message); + const target = findLastOwnEditable(scope); + if (!target) return false; + onEdit(target); + return true; + }, [findLastOwnEditable, onEdit, threadHeadMessage, visibleThreadMessages]); useRenderScopedReactionHydration({ activeChannel, mainTimelineEntries, threadHeadMessage, - threadMessages, + threadMessages: visibleThreadMessages, }); const videoReviewCommentsByRootId = React.useMemo( () => buildVideoReviewCommentsByRootId(messages), @@ -667,6 +792,7 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : null} { const panel = ( - ) : null - } /> ); return wrapAux(panel, "message-thread-panel"); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 13164cf5e..9b3b309ed 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -3,6 +3,7 @@ import { useAppShell } from "@/app/AppShellContext"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader"; import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers"; +import { buildAgentConversationMarkers } from "@/features/agents/agentConversations"; import { useChannelMembersQuery, useJoinChannelMutation, @@ -17,6 +18,10 @@ import { ChannelPane, ForumView, } from "@/features/channels/ui/ChannelScreenLazyViews"; +import { + getDmAutoRouteAgentPubkeys, + getThreadAutoRouteAgentPubkeys, +} from "@/features/channels/ui/ChannelPane.helpers"; import { MembersSidebar } from "@/features/channels/ui/MembersSidebar"; import { useManagedAgentsQuery, @@ -402,6 +407,10 @@ export function ChannelScreen({ resolvedMessages, ], ); + const agentConversationMarkers = React.useMemo( + () => buildAgentConversationMarkers(resolvedMessages), + [resolvedMessages], + ); const channelFind = useChannelFind({ channelId: activeChannelId, messages: timelineMessages, @@ -440,6 +449,37 @@ export function ChannelScreen({ timelineMessages.find((message) => message.id === editTargetId) ?? null, [editTargetId, timelineMessages], ); + const routingAgentPubkeys = React.useMemo(() => { + const pubkeys = new Set(agentPubkeys); + for (const [pubkey, profile] of Object.entries(messageProfiles)) { + if (profile?.isAgent) { + pubkeys.add(normalizePubkey(pubkey)); + } + } + return pubkeys; + }, [agentPubkeys, messageProfiles]); + const messageAutoRouteAgentPubkeys = React.useMemo( + () => + getDmAutoRouteAgentPubkeys({ + channel: activeChannel, + currentPubkey, + knownAgentPubkeys: routingAgentPubkeys, + }), + [activeChannel, currentPubkey, routingAgentPubkeys], + ); + const threadAutoRouteAgentPubkeys = React.useMemo(() => { + if (!openThreadHeadMessage) { + return []; + } + + return getThreadAutoRouteAgentPubkeys({ + knownAgentPubkeys: routingAgentPubkeys, + messages: [ + openThreadHeadMessage, + ...threadMessages.map((entry) => entry.message), + ], + }); + }, [openThreadHeadMessage, routingAgentPubkeys, threadMessages]); const { handleCancelEdit, handleCancelThreadReply, @@ -457,6 +497,7 @@ export function ChannelScreen({ deleteMessageMutation, editMessageMutation, editTargetId, + messageAutoRouteAgentPubkeys, expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, @@ -469,6 +510,7 @@ export function ChannelScreen({ setOpenThreadHeadId, setThreadReplyTargetId, setThreadScrollTargetId, + threadAutoRouteAgentPubkeys, threadReplyTargetId, toggleReactionMutation, }); @@ -757,6 +799,7 @@ export function ChannelScreen({ ; editMessageMutation: ReturnType; editTargetId: string | null; + messageAutoRouteAgentPubkeys: readonly string[]; expandedThreadReplyIds: ReadonlySet; getFirstReplyIdForMessage: (messageId: string) => string | null; getReplyDescendantIdsForMessage: (messageId: string) => string[]; @@ -53,6 +57,7 @@ export function useChannelPaneHandlers({ setOpenThreadHeadId: (value: string | null) => void; setThreadReplyTargetId: React.Dispatch>; setThreadScrollTargetId: React.Dispatch>; + threadAutoRouteAgentPubkeys: readonly string[]; threadReplyTargetId: string | null; toggleReactionMutation: ReturnType; }) { @@ -69,6 +74,16 @@ export function useChannelPaneHandlers({ const expandedThreadReplyIdsRef = React.useRef(expandedThreadReplyIds); expandedThreadReplyIdsRef.current = expandedThreadReplyIds; + const messageAutoRouteAgentPubkeysRef = React.useRef( + messageAutoRouteAgentPubkeys, + ); + messageAutoRouteAgentPubkeysRef.current = messageAutoRouteAgentPubkeys; + + const threadAutoRouteAgentPubkeysRef = React.useRef( + threadAutoRouteAgentPubkeys, + ); + threadAutoRouteAgentPubkeysRef.current = threadAutoRouteAgentPubkeys; + const sendMutateRef = React.useRef(sendMessageMutation.mutateAsync); sendMutateRef.current = sendMessageMutation.mutateAsync; @@ -227,7 +242,10 @@ export function useChannelPaneHandlers({ ) => { await sendMutateRef.current({ content, - mentionPubkeys, + mentionPubkeys: mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys: messageAutoRouteAgentPubkeysRef.current, + mentionPubkeys, + }), mediaTags, }); }, @@ -261,7 +279,10 @@ export function useChannelPaneHandlers({ const sentMessage = await sendMutateRef.current({ content, - mentionPubkeys, + mentionPubkeys: mergeAutoRouteMentionPubkeys({ + autoRouteAgentPubkeys: threadAutoRouteAgentPubkeysRef.current, + mentionPubkeys, + }), parentEventId, mediaTags, }); diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 39d8b46dc..4b24ca4f1 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -16,22 +16,30 @@ import { toast } from "sonner"; import type { ChannelType, ChannelVisibility } from "@/shared/api/types"; import { UpdateIndicator } from "@/features/settings/UpdateIndicator"; import { cn } from "@/shared/lib/cn"; +import { AnimatedTextSwap } from "@/shared/ui/AnimatedTextSwap"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { Button } from "@/shared/ui/button"; +import { useOptionalSidebar } from "@/shared/ui/sidebar"; type ChatHeaderProps = { actions?: React.ReactNode; + animatedTitle?: boolean; + animatedTitleResetKey?: string; belowSystemChrome?: boolean; + compactTitleStack?: boolean; /** Ref to the outer chrome wrapper when `belowSystemChrome` is true. */ chromeWrapperRef?: React.Ref; title: string; description?: string; channelType?: ChannelType; visibility?: ChannelVisibility; - leadingContent?: React.ReactNode; + leadingContent?: React.ReactNode | false; + leadingContentContainerClassName?: string; + leadingContentLayout?: "inline" | "side"; mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects"; overlaysContent?: boolean; statusBadge?: React.ReactNode; + subtitle?: string | null; }; const HEADER_ICON_CLASS = "h-4 w-4 text-muted-foreground"; @@ -83,18 +91,28 @@ function ChannelIcon({ export function ChatHeader({ actions, + animatedTitle = false, + animatedTitleResetKey, belowSystemChrome = false, + compactTitleStack = false, chromeWrapperRef, title, description, channelType, visibility, leadingContent, + leadingContentContainerClassName, + leadingContentLayout = "inline", mode = "channel", overlaysContent = false, statusBadge, + subtitle, }: ChatHeaderProps) { const trimmedDescription = description?.trim() ?? ""; + const trimmedSubtitle = subtitle?.trim() ?? ""; + const sidebar = useOptionalSidebar(); + const clearCollapsedTopChromeControls = + belowSystemChrome && sidebar?.state === "collapsed" && !sidebar.isMobile; async function handleCopyTitle() { const value = title.trim(); @@ -108,37 +126,69 @@ export function ChatHeader({ } } + const renderedLeadingContent = + leadingContent === false + ? null + : (leadingContent ?? ( + + )); + const header = (
-
+
+ {renderedLeadingContent && leadingContentLayout === "side" ? ( +
+ {renderedLeadingContent} +
+ ) : null}
-
- {leadingContent ?? ( - - )} -
+ {renderedLeadingContent && leadingContentLayout === "inline" ? ( +
+ {renderedLeadingContent} +
+ ) : null}

- {title} + {animatedTitle ? ( + + ) : ( + title + )}

) : null}
+ {trimmedSubtitle ? ( +

+ {trimmedSubtitle} +

+ ) : null}
diff --git a/desktop/src/features/messages/lib/threadPanel.test.mjs b/desktop/src/features/messages/lib/threadPanel.test.mjs index 299d34b14..7da7e129e 100644 --- a/desktop/src/features/messages/lib/threadPanel.test.mjs +++ b/desktop/src/features/messages/lib/threadPanel.test.mjs @@ -66,7 +66,7 @@ test("buildMainTimelineEntries includes broadcast replies", () => { ); }); -test("buildThreadPanelData connects direct comments to the thread head", () => { +test("buildThreadPanelData flattens every descendant into the thread panel", () => { const root = message({ id: "root", createdAt: 1 }); const directComment = message({ id: "direct-comment", @@ -99,15 +99,16 @@ test("buildThreadPanelData connects direct comments to the thread head", () => { panelData.visibleReplies.map((entry) => ({ id: entry.message.id, depth: entry.message.depth, + summary: entry.summary, })), [ - { id: "direct-comment", depth: 1 }, - { id: "nested-reply", depth: 2 }, + { id: "direct-comment", depth: 1, summary: null }, + { id: "nested-reply", depth: 1, summary: null }, ], ); }); -test("buildThreadPanelData hides collapsed summaries for expanded replies", () => { +test("buildThreadPanelData does not hide nested replies behind collapsed summaries", () => { const root = message({ id: "root", createdAt: 1 }); const branch = message({ id: "branch", @@ -142,11 +143,21 @@ test("buildThreadPanelData hides collapsed summaries for expanded replies", () = new Set(["branch"]), ); - assert.equal(collapsed.visibleReplies[0].summary?.replyCount, 1); - assert.equal(expanded.visibleReplies[0].summary, null); + assert.deepEqual( + collapsed.visibleReplies.map((entry) => ({ + id: entry.message.id, + depth: entry.message.depth, + summary: entry.summary, + })), + [ + { id: "branch", depth: 1, summary: null }, + { id: "child", depth: 1, summary: null }, + ], + ); + assert.deepEqual(expanded.visibleReplies, collapsed.visibleReplies); }); -test("buildThreadSummaryFromVisibleEntries counts visible rows and hidden descendants", () => { +test("buildThreadSummaryFromVisibleEntries counts flattened visible rows", () => { const root = message({ id: "root", createdAt: 1 }); const branch = message({ id: "branch", @@ -239,7 +250,7 @@ test("hasNestedThreadBranches returns false for flat direct replies", () => { assert.equal(hasNestedThreadBranches(panelData.visibleReplies), false); }); -test("hasNestedThreadBranches returns true for visible nested replies", () => { +test("hasNestedThreadBranches returns false for flattened nested replies", () => { const root = message({ id: "root", createdAt: 1 }); const branch = message({ id: "branch", @@ -263,10 +274,10 @@ test("hasNestedThreadBranches returns true for visible nested replies", () => { new Set(["branch"]), ); - assert.equal(hasNestedThreadBranches(panelData.visibleReplies), true); + assert.equal(hasNestedThreadBranches(panelData.visibleReplies), false); }); -test("hasNestedThreadBranches returns true for collapsed nested replies", () => { +test("hasNestedThreadBranches returns false for previously collapsed nested replies", () => { const root = message({ id: "root", createdAt: 1 }); const branch = message({ id: "branch", @@ -290,7 +301,7 @@ test("hasNestedThreadBranches returns true for collapsed nested replies", () => new Set(), ); - assert.equal(hasNestedThreadBranches(panelData.visibleReplies), true); + assert.equal(hasNestedThreadBranches(panelData.visibleReplies), false); }); test("shouldRenderUnreadDivider_firstUnreadIsFirstRendered_suppressesDivider", () => { diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index ebf746e0c..d1e0035c6 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -314,70 +314,58 @@ export function hasNestedThreadBranches(entries: readonly MainTimelineEntry[]) { ); } -function appendExpandedReplies(params: { - entries: MainTimelineEntry[]; - parentId: string; - depth: number; - directChildrenByParentId: Map; - descendantStatsByMessageId: Map; - expandedReplyIds: ReadonlySet; -}) { - const { - entries, - parentId, - depth, - directChildrenByParentId, - descendantStatsByMessageId, - expandedReplyIds, - } = params; - const directReplies = directChildrenByParentId.get(parentId) ?? []; - - for (const reply of directReplies) { - const isExpanded = expandedReplyIds.has(reply.id); - entries.push({ - message: normalizeInlineReplyMessage(reply, depth), - summary: isExpanded - ? null - : buildSummaryForDirectReplies(reply.id, descendantStatsByMessageId), - }); +function isDescendantOfMessage( + message: TimelineMessage, + ancestorId: string, + messageById: Map, +) { + if (message.id === ancestorId) { + return false; + } - if (isExpanded) { - appendExpandedReplies({ - entries, - parentId: reply.id, - depth: depth + 1, - directChildrenByParentId, - descendantStatsByMessageId, - expandedReplyIds, - }); + if (message.rootId === ancestorId || message.parentId === ancestorId) { + return true; + } + + let parentId = message.parentId ?? null; + let hops = 0; + const maxHops = messageById.size + 1; + while (parentId && hops < maxHops) { + if (parentId === ancestorId) { + return true; } + + parentId = messageById.get(parentId)?.parentId ?? null; + hops += 1; } + + return false; } function buildVisibleThreadReplies(params: { openThreadHeadId: string; - directChildrenByParentId: Map; - descendantStatsByMessageId: Map; - expandedReplyIds: ReadonlySet; + messageById: Map; }) { - const { - openThreadHeadId, - directChildrenByParentId, - descendantStatsByMessageId, - expandedReplyIds, - } = params; - const entries: MainTimelineEntry[] = []; - - appendExpandedReplies({ - entries, - parentId: openThreadHeadId, - depth: 1, - directChildrenByParentId, - descendantStatsByMessageId, - expandedReplyIds, - }); + const { openThreadHeadId, messageById } = params; - return entries; + return [...messageById.values()] + .map((message, index) => ({ index, message })) + .filter(({ message }) => + isDescendantOfMessage(message, openThreadHeadId, messageById), + ) + .sort((left, right) => { + if (left.message.createdAt !== right.message.createdAt) { + return left.message.createdAt - right.message.createdAt; + } + + return left.index - right.index; + }) + .map( + ({ message }): MainTimelineEntry => ({ + message: normalizeInlineReplyMessage(message, 1), + summary: null, + }), + ); } export function buildMainTimelineEntries( @@ -424,7 +412,7 @@ export function buildThreadPanelDataFromIndex( index: ThreadPanelIndex, openThreadHeadId: string | null, threadReplyTargetId: string | null, - expandedReplyIds: ReadonlySet, + _expandedReplyIds: ReadonlySet, ): ThreadPanelData { if (!openThreadHeadId) { return { @@ -435,8 +423,7 @@ export function buildThreadPanelDataFromIndex( }; } - const { directChildrenByParentId, descendantStatsByMessageId, messageById } = - index; + const { descendantStatsByMessageId, messageById } = index; const threadHead = messageById.get(openThreadHeadId) ?? null; if (!threadHead) { @@ -451,9 +438,7 @@ export function buildThreadPanelDataFromIndex( const normalizedThreadHead = normalizeHeadMessage(threadHead); const visibleReplies = buildVisibleThreadReplies({ openThreadHeadId, - directChildrenByParentId, - descendantStatsByMessageId, - expandedReplyIds, + messageById, }); const replyTargetInBranch = diff --git a/desktop/src/features/messages/ui/AgentConversationMarkerRow.tsx b/desktop/src/features/messages/ui/AgentConversationMarkerRow.tsx new file mode 100644 index 000000000..79795504c --- /dev/null +++ b/desktop/src/features/messages/ui/AgentConversationMarkerRow.tsx @@ -0,0 +1,351 @@ +import { MessagesSquare } from "lucide-react"; + +import type { AgentConversationMarker } from "@/features/agents/agentConversations"; +import type { TimelineMessage } from "@/features/messages/types"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { Button } from "@/shared/ui/button"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +type AgentConversationMarkerRowProps = { + className?: string; + currentPubkey?: string; + marker: AgentConversationMarker; + message: TimelineMessage; + onOpenAgentConversation?: ( + message: TimelineMessage, + options?: { publishMarker?: boolean }, + ) => void; + profiles?: UserProfileLookup; +}; + +const RECAP_SECTION_PATTERN = + /\*\*(Original request|Findings|Outcome|Next steps):\*\*/g; +const RECAP_DETAIL_TAIL_PATTERN = + /\b(?:Nothing else needed|Nice work|Kenny asked|The user asked|Button system\s*(?:\(|[—-])|Composer primitives\s*(?:\(|[—-])|Sidebar navigation\s*(?:\(|[—-])|Key gotcha\s*:|Decisions?\s*:|Team agreed\b)[\s\S]*$/i; + +function parseRecapSections(value: string): Map { + const matches = [...value.matchAll(RECAP_SECTION_PATTERN)]; + const sections = new Map(); + if (matches.length === 0) { + return sections; + } + + matches.forEach((match, index) => { + const label = match[1]; + const start = (match.index ?? 0) + match[0].length; + const end = + index + 1 < matches.length + ? (matches[index + 1].index ?? value.length) + : value.length; + const content = value.slice(start, end).trim(); + if (content) { + sections.set(label, content); + } + }); + + return sections; +} + +function stripRecapMarkdown(value: string): string { + return value + .replace(RECAP_SECTION_PATTERN, "") + .replace(/\bConversation recap:\s*/gi, "") + .replace(/^\s*[\w .'-]{1,40}:\s+(?=\S)/gm, "") + .replace(/\[([^\]]+)]\([^)]+\)/g, "$1") + .replace(/[`*_~>#]/g, "") + .replace(/^\s*[-*]\s+/gm, "") + .replace(/(?:^|\s)\d+\.\s+/g, " ") + .replace(/\n+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function sentenceCase(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + + return `${trimmed.charAt(0).toLocaleUpperCase()}${trimmed.slice(1)}`; +} + +function ensureSentence(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + + return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`; +} + +function formatJoinedList(items: readonly string[]): string { + if (items.length <= 1) { + return items[0] ?? ""; + } + if (items.length === 2) { + return `${items[0]} and ${items[1]}`; + } + + return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`; +} + +function cleanPreviewTopic(title: string): string | null { + const topic = title.trim(); + if ( + !topic || + topic.toLocaleLowerCase() === "new conversation" || + /^conversation(?:\s+(?:in|about|with)\b.*)?$/i.test(topic) + ) { + return null; + } + + return topic; +} + +function extractCoveredAreas(value: string): string[] { + const areas: string[] = []; + const seen = new Set(); + const areaPattern = + /(?:^|[.!?]\s+)([A-Z][A-Za-z0-9 /&+-]{2,70})(?:\s*\([^)]*\))?\s+[—-]\s+/g; + + for (const match of value.matchAll(areaPattern)) { + const area = match[1]?.replace(/\s+/g, " ").trim(); + if (!area) { + continue; + } + + const normalized = area.toLocaleLowerCase(); + if ( + seen.has(normalized) || + /^(key gotcha|decisions?|next steps?|conversation recap)$/i.test(area) + ) { + continue; + } + + seen.add(normalized); + areas.push(area); + if (areas.length >= 4) { + break; + } + } + + return areas; +} + +function extractLabeledText( + value: string, + labelPattern: string, +): string | null { + const labelRegex = new RegExp( + `(?:^|[.\\n]\\s*)${labelPattern}\\s*:\\s*([\\s\\S]*?)(?=(?:^|[.\\n]\\s*)(?:Key gotcha|Decisions?|Next steps(?:\\s*\\([^)]*\\))?|Original request|Findings|Outcome)\\s*:|$)`, + "i", + ); + const match = value.match(labelRegex); + const text = stripRecapMarkdown(match?.[1] ?? ""); + + return text || null; +} + +function stripRecapDetailTail(value: string): string { + return value.replace(RECAP_DETAIL_TAIL_PATTERN, "").trim(); +} + +function cleanNextStepsPreviewText(value: string | null): string | null { + const cleaned = stripRecapDetailTail(value ?? "") + .replace(/^pending [^:]+:\s*/i, "") + .replace(/^,\s*/, "") + .replace(/\s+/g, " ") + .trim(); + + return cleaned || null; +} + +function firstUsefulSentence(value: string): string | null { + const sentences = value + .split(/(?<=[.!?])\s+/) + .map((sentence) => sentence.trim()) + .filter(Boolean); + + return ( + sentences.find( + (sentence) => + !/^(kenny asked|the user asked|conversation recap)\b/i.test(sentence), + ) ?? + sentences[0] ?? + null + ); +} + +function lowerFirst(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + + return `${trimmed.charAt(0).toLocaleLowerCase()}${trimmed.slice(1)}`; +} + +function formatNextStepsText(value: string): string { + const normalized = value.replace(/^to\s+/i, "").trim(); + const items = normalized + .split( + /\s+(?=(?:Publish|Link|Cross-link|Follow-up|Follow up|Add|Create|Update|Review|Share|Ship|Document)\b)/, + ) + .map((item) => item.trim()) + .filter(Boolean); + const formatted = + items.length <= 1 + ? lowerFirst(normalized) + : formatJoinedList(items.map(lowerFirst)); + + if (/^(?:to|until|once)\b/i.test(formatted)) { + return formatted; + } + + return `to ${formatted}`; +} + +function buildRecapPreview(summary: string, title: string): string { + const sections = parseRecapSections(summary); + const outcome = stripRecapMarkdown(sections.get("Outcome") ?? ""); + const findings = stripRecapMarkdown(sections.get("Findings") ?? ""); + const nextSteps = stripRecapMarkdown(sections.get("Next steps") ?? ""); + const source = [outcome, findings].filter(Boolean).join(" ").trim(); + const fullText = stripRecapMarkdown(summary); + const topic = cleanPreviewTopic(title); + const areas = extractCoveredAreas(source || fullText); + const decision = extractLabeledText(summary, "Decisions?"); + const rawNextSteps = + nextSteps || extractLabeledText(summary, "Next steps(?:\\s*\\([^)]*\\))?"); + const nextStepText = cleanNextStepsPreviewText(rawNextSteps); + const fallbackSentence = firstUsefulSentence( + stripRecapDetailTail(source || fullText), + ); + const sentences: string[] = []; + + if (topic) { + sentences.push(`This conversation focused on ${topic}.`); + } + + if (areas.length > 0) { + const areaText = formatJoinedList(topic ? areas : areas.map(lowerFirst)); + sentences.push( + topic + ? `The main takeaways covered ${areaText}.` + : ensureSentence(sentenceCase(areaText)), + ); + } else if (fallbackSentence) { + sentences.push(ensureSentence(sentenceCase(fallbackSentence))); + } + + if (decision) { + sentences.push(ensureSentence(sentenceCase(decision))); + } + + if (nextStepText) { + sentences.push( + `Next steps are ${ensureSentence(formatNextStepsText(nextStepText))}`, + ); + } + + const preview = sentences.join(" ").replace(/\s+/g, " ").trim(); + + return ( + preview || + (fallbackSentence + ? ensureSentence(sentenceCase(fallbackSentence)) + : fullText) + ); +} + +export function AgentConversationMarkerRow({ + className, + currentPubkey, + marker, + message, + onOpenAgentConversation, + profiles, +}: AgentConversationMarkerRowProps) { + const starterProfile = profiles?.[normalizePubkey(marker.starterPubkey)]; + const starterName = resolveUserLabel({ + currentPubkey, + profiles, + pubkey: marker.starterPubkey, + }); + const recapPreview = marker.summary + ? buildRecapPreview(marker.summary, marker.title) + : null; + + return ( +
+ +
+
+
+
+ +
+
+

+ Dedicated conversation +

+

+ {marker.title} +

+
+ +
+ {marker.summary ? ( +
+

+ Conversation recap +

+ {recapPreview ? ( +

+ {recapPreview} +

+ ) : null} +
+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 8eaa67db6..a16f3c55a 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, + MessagesSquare, Pencil, SmilePlus, Trash2, @@ -342,6 +343,7 @@ export function MessageActionBar({ onMarkRead, onReactionBadgeBurstRequest, onReactionSelect, + onContinueConversation, onRemindLater, onReply, onUnfollowThread, @@ -361,6 +363,7 @@ export function MessageActionBar({ onMarkRead?: (message: TimelineMessage) => void; onReactionBadgeBurstRequest?: (emoji: string) => void; onReactionSelect?: (emoji: string) => Promise; + onContinueConversation?: (message: TimelineMessage) => void; onRemindLater?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; onUnfollowThread?: (message: TimelineMessage) => void; @@ -389,6 +392,7 @@ export function MessageActionBar({ ); const hasReplyAction = Boolean(onReply); const hasReactionAction = Boolean(onReactionSelect); + const hasContinueConversationAction = Boolean(onContinueConversation); const hasMoreMenuActions = Boolean(onEdit) || @@ -431,7 +435,12 @@ export function MessageActionBar({ [onReactionBadgeBurstRequest, onReactionSelect, wouldAddReaction], ); - if (!hasReplyAction && !hasReactionAction && !hasMoreMenuActions) { + if ( + !hasReplyAction && + !hasReactionAction && + !hasContinueConversationAction && + !hasMoreMenuActions + ) { return null; } @@ -513,6 +522,27 @@ export function MessageActionBar({ ) : null} + {hasContinueConversationAction ? ( + + + + + Continue conversation + + ) : null} + {hasReplyAction ? ( diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 37dbbe774..2778c3fd7 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { EditorContent } from "@tiptap/react"; -import { CornerUpLeft, Pencil, X } from "lucide-react"; +import { CornerUpLeft, Info, Pencil, X } from "lucide-react"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; @@ -59,6 +59,7 @@ type MessageComposerProps = { containerClassName?: string; disabled?: boolean; draftKey?: string; + composerNotice?: React.ReactNode; editTarget?: { author: string; body: string; @@ -111,6 +112,7 @@ export function MessageComposer({ containerClassName, disabled = false, draftKey, + composerNotice, editTarget = null, isSending = false, onCancelEdit, @@ -878,6 +880,14 @@ export function MessageComposer({ ) : null}
+ ) : composerNotice ? ( +
+ +
{composerNotice}
+
) : null}
import("./DiffMessage")); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); +const AGENT_STATUS_REACTION_EMOJIS = new Set(["👀", "💬"]); export type ThreadDepthGuideAction = { active?: boolean; @@ -43,6 +47,66 @@ export type ThreadDepthGuideAction = { message: TimelineMessage; }; +function stripAgentStatusReactionUsers( + reaction: TimelineReaction, + agentPubkeys: ReadonlySet, +): TimelineReaction | null { + if (!AGENT_STATUS_REACTION_EMOJIS.has(reaction.emoji)) { + return reaction; + } + + const remainingUsers = reaction.users.filter( + (user) => !agentPubkeys.has(normalizePubkey(user.pubkey)), + ); + const removedCount = reaction.users.length - remainingUsers.length; + if (removedCount <= 0) { + return reaction; + } + + const nextCount = Math.max(0, reaction.count - removedCount); + if (nextCount === 0) { + return null; + } + + return { + ...reaction, + count: nextCount, + users: remainingUsers, + }; +} + +function stripAgentStatusReactions( + message: TimelineMessage, + agentPubkeys: ReadonlySet, +) { + if (!message.reactions?.length || agentPubkeys.size === 0) { + return message; + } + + let didChange = false; + const reactions = message.reactions + .map((reaction) => { + const nextReaction = stripAgentStatusReactionUsers( + reaction, + agentPubkeys, + ); + if (nextReaction !== reaction) { + didChange = true; + } + return nextReaction; + }) + .filter((reaction): reaction is TimelineReaction => reaction !== null); + + if (!didChange) { + return message; + } + + return { + ...message, + reactions: reactions.length > 0 ? reactions : undefined, + }; +} + export const MessageRow = React.memo( function MessageRow({ channelId = null, @@ -69,6 +133,7 @@ export const MessageRow = React.memo( onFollowThread, onMarkUnread, onMarkRead, + onOpenAgentConversation, onToggleReaction, onReply, onUnfollowThread, @@ -109,6 +174,10 @@ export const MessageRow = React.memo( onFollowThread?: (message: TimelineMessage) => void; onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; + onOpenAgentConversation?: ( + message: TimelineMessage, + options?: { publishMarker?: boolean }, + ) => void; onToggleReaction?: ( message: TimelineMessage, emoji: string, @@ -127,13 +196,28 @@ export const MessageRow = React.memo( const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState( null, ); + const resolvedAgentPubkeys = React.useMemo(() => { + const pubkeys = new Set(agentPubkeys ?? []); + + for (const [pubkey, profile] of Object.entries(profiles ?? {})) { + if (profile.isAgent) { + pubkeys.add(normalizePubkey(pubkey)); + } + } + + return pubkeys; + }, [agentPubkeys, profiles]); + const messageForReactions = React.useMemo( + () => stripAgentStatusReactions(message, resolvedAgentPubkeys), + [message, resolvedAgentPubkeys], + ); const { reactions, canToggle: canToggleReactions, pending: reactionPending, errorMessage: reactionErrorMessage, select: handleReactionSelect, - } = useReactionHandler(message, onToggleReaction); + } = useReactionHandler(messageForReactions, onToggleReaction); const { openReminder, activeReminderEventIds } = useRemindLater(); const hasActiveReminder = activeReminderEventIds.has(message.id); const mentionNames = React.useMemo( @@ -144,17 +228,6 @@ export const MessageRow = React.memo( () => resolveMentionPubkeysByName(message.tags, profiles), [profiles, message.tags], ); - const resolvedAgentPubkeys = React.useMemo(() => { - const pubkeys = new Set(agentPubkeys ?? []); - - for (const [pubkey, profile] of Object.entries(profiles ?? {})) { - if (profile.isAgent) { - pubkeys.add(normalizePubkey(pubkey)); - } - } - - return pubkeys; - }, [agentPubkeys, profiles]); const agentMentionPubkeysByName = React.useMemo(() => { if (!mentionPubkeysByName) { return undefined; @@ -180,7 +253,10 @@ export const MessageRow = React.memo( message.tags, ); const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5"; - + const isAgentMessage = + message.pubkey != null && + !message.pending && + resolvedAgentPubkeys.has(normalizePubkey(message.pubkey)); const { channels } = useChannelNavigation(); const channelNames = React.useMemo( () => channels.filter((c) => c.channelType !== "dm").map((c) => c.name), @@ -353,6 +429,9 @@ export const MessageRow = React.memo( isFollowingThread={isFollowingThread} isUnread={isUnread} message={message} + onContinueConversation={ + isAgentMessage ? onOpenAgentConversation : undefined + } onDelete={onDelete} onEdit={onEdit} onFollowThread={onFollowThread} @@ -745,6 +824,7 @@ export const MessageRow = React.memo( prev.message.tags === next.message.tags && prev.message.role === next.message.role && prev.message.personaDisplayName === next.message.personaDisplayName && + prev.agentPubkeys === next.agentPubkeys && prev.collapseDepthGuideActions === next.collapseDepthGuideActions && prev.collapseDescendantsLabel === next.collapseDescendantsLabel && prev.connectDescendants === next.connectDescendants && @@ -763,6 +843,8 @@ export const MessageRow = React.memo( prev.onCollapseDescendants === next.onCollapseDescendants && prev.onCollapseDescendantsHoverChange === next.onCollapseDescendantsHoverChange && + Boolean(prev.onOpenAgentConversation) === + Boolean(next.onOpenAgentConversation) && prev.profiles === next.profiles && prev.searchQuery === next.searchQuery && prev.videoReviewContext === next.videoReviewContext, diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index b45449e8f..54f541fe1 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -1,11 +1,10 @@ import * as React from "react"; import { ArrowDown, ArrowLeft, X } from "lucide-react"; -import { - buildThreadSummaryFromVisibleEntries, - hasNestedThreadBranches, - type MainTimelineEntry, -} from "@/features/messages/lib/threadPanel"; +import type { AgentConversationMarker } from "@/features/agents/agentConversations"; +import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; +import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; +import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -27,11 +26,14 @@ import { PANEL_OVERLAY_CLASS, PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS, } from "@/shared/ui/OverlayPanelBackdrop"; +import { Shimmer } from "@/shared/ui/Shimmer"; import { Skeleton } from "@/shared/ui/skeleton"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; +import { AgentConversationMarkerRow } from "./AgentConversationMarkerRow"; +import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageComposer } from "./MessageComposer"; -import { MessageRow, type ThreadDepthGuideAction } from "./MessageRow"; -import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; +import { MessageRow } from "./MessageRow"; import { TypingIndicatorRow } from "./TypingIndicatorRow"; import { UnreadDivider } from "./UnreadDivider"; import { useComposerHeightPadding } from "./useComposerHeightPadding"; @@ -39,6 +41,7 @@ import { useAnchoredScroll } from "./useAnchoredScroll"; import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot"; type MessageThreadPanelProps = { + agentConversationMarkers?: readonly AgentConversationMarker[]; agentPubkeys?: ReadonlySet; channel: Channel | null; channelId: string | null; @@ -64,6 +67,10 @@ type MessageThreadPanelProps = { onEditSave?: (content: string, mediaTags?: string[][]) => Promise; onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; + onOpenAgentConversation?: ( + message: TimelineMessage, + options?: { publishMarker?: boolean }, + ) => void; onExpandReplies: (message: TimelineMessage) => void; onScrollTargetResolved: () => void; onSelectReplyTarget: (message: TimelineMessage) => void; @@ -84,9 +91,9 @@ type MessageThreadPanelProps = { threadReplies: MainTimelineEntry[]; threadUnreadCount?: number; threadReplyUnreadCounts?: ReadonlyMap; + threadActivityAgents?: readonly ThreadActivityAgent[]; threadTypingPubkeys: string[]; threadHeadVideoReviewContext?: VideoReviewContext; - toolbarExtraActions?: React.ReactNode; widthPx: number; isFollowingThread?: boolean; isMessageUnreadById?: (messageId: string) => boolean; @@ -94,10 +101,16 @@ type MessageThreadPanelProps = { onUnfollowThread?: () => void; }; +type ThreadActivityAgent = { + name: string; + pubkey: string; +}; + +/** Stable `useDeferredValue` initial value; mirrors `EMPTY_MESSAGES`. */ const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = []; const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-2"; const THREAD_PANEL_COMPOSER_GUTTER_CLASS = "px-5"; -const THREAD_PANEL_SUMMARY_INDENT_OFFSET_REM = -0.125; + type MessageThreadPanelSkeletonProps = { isSinglePanelView?: boolean; layout?: "standalone" | "split"; @@ -116,55 +129,101 @@ function canManageMessage( ); } -function hasLaterVisibleSibling( - entries: readonly MainTimelineEntry[], - entryIndex: number, -): boolean { - const depth = entries[entryIndex]?.message.depth; - if (depth == null) { - return false; +function normalizeActivityText(value: string) { + return value.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); +} + +function getActivityLabel(item: TranscriptItem): string { + if (item.type === "message") { + return item.role === "assistant" ? "Responding..." : "Thinking..."; + } + + if (item.type !== "tool") { + return "Thinking..."; + } + + const activityText = normalizeActivityText( + [item.buzzToolName, item.toolName, item.title] + .filter((value): value is string => Boolean(value)) + .join(" "), + ); + + if (/\b(send message|send)\b/.test(activityText)) { + return "Responding..."; } - for (let index = entryIndex + 1; index < entries.length; index += 1) { - const nextDepth = entries[index].message.depth; - if (nextDepth <= depth) { - return nextDepth === depth; - } + if ( + /\b(review|diff|compare|pull request|pr|changes?|patch)\b/.test( + activityText, + ) + ) { + return "Reviewing..."; } - return false; + if ( + /\b(edit|write|update|create|delete|set|add|remove|join|leave|archive|unarchive|publish|trigger|approve|vote)\b/.test( + activityText, + ) + ) { + return "Editing..."; + } + + if ( + /\b(search|find|lookup|query|fetch|get|list|read|retrieve|history|thread|channel|user|feed|canvas|presence)\b/.test( + activityText, + ) + ) { + return "Searching..."; + } + + return "Thinking..."; } -function getActiveContinuationDepths({ - ancestors, - entries, - index, - message, +function ThreadAgentActivityRow({ + agent, + channelId, + profiles, }: { - ancestors: readonly { index: number; message: TimelineMessage }[]; - entries: readonly MainTimelineEntry[]; - index: number; - message: TimelineMessage; -}): number[] { - const depths: number[] = []; - - for (const ancestor of ancestors) { - if (ancestor.message.depth === 0) { - continue; - } - - const childDepth = ancestor.message.depth + 1; - const pathChild = - message.depth === childDepth - ? { index, message } - : ancestors.find((candidate) => candidate.message.depth === childDepth); - - if (pathChild && hasLaterVisibleSibling(entries, pathChild.index)) { - depths.push(ancestor.message.depth); - } - } + agent: ThreadActivityAgent; + channelId: string | null; + profiles?: UserProfileLookup; +}) { + const transcript = useAgentTranscript(true, agent.pubkey); + const activityLabel = React.useMemo(() => { + const scopedTranscript = channelId + ? transcript.filter((item) => item.channelId === channelId) + : transcript; + + const latestActivity = scopedTranscript[scopedTranscript.length - 1]; + return latestActivity ? getActivityLabel(latestActivity) : "Thinking..."; + }, [channelId, transcript]); + const profile = profiles?.[agent.pubkey.toLowerCase()]; - return depths; + return ( +
+ +
+ + + {profile?.displayName || agent.name} + + +

+ {activityLabel} +

+
+
+ ); } function ThreadMessageSkeleton({ isHead = false }: { isHead?: boolean }) { @@ -339,6 +398,7 @@ export function MessageThreadPanelSkeleton({ } export function MessageThreadPanel({ + agentConversationMarkers, agentPubkeys, channel, channelId, @@ -362,7 +422,7 @@ export function MessageThreadPanel({ onFollowThread, onMarkUnread, onMarkRead, - onExpandReplies, + onOpenAgentConversation, onScrollTargetResolved, onSelectReplyTarget, onSend, @@ -373,22 +433,14 @@ export function MessageThreadPanel({ scrollTargetId, threadHead, threadHeadVideoReviewContext, + threadActivityAgents = [], threadReplies, - threadUnreadCount, - threadReplyUnreadCounts, threadTypingPubkeys, - toolbarExtraActions, widthPx, }: MessageThreadPanelProps) { const threadBodyRef = React.useRef(null); const threadContentRef = React.useRef(null); const threadComposerWrapperRef = React.useRef(null); - const [hoveredCollapseBranchId, setHoveredCollapseBranchId] = React.useState< - string | null - >(null); - const [collapsedThreadHeadId, setCollapsedThreadHeadId] = React.useState< - string | null - >(null); const isOverlay = useIsThreadPanelOverlay(); const isFloatingOverlay = isOverlay && !isSinglePanelView; const isSplitLayout = layout === "split"; @@ -400,42 +452,6 @@ export function MessageThreadPanel({ isSinglePanelView, ); - const collapseThreadHeadReplies = React.useCallback(() => { - if (!threadHeadId) { - return; - } - - setHoveredCollapseBranchId(null); - setCollapsedThreadHeadId(threadHeadId); - }, [threadHeadId]); - const expandThreadHeadReplies = React.useCallback(() => { - setHoveredCollapseBranchId(null); - setCollapsedThreadHeadId(null); - }, []); - const handleCollapseBranchHoverChange = React.useCallback( - (message: TimelineMessage, hovered: boolean) => { - setHoveredCollapseBranchId((current) => { - if (hovered) { - return message.id; - } - - return current === message.id ? null : current; - }); - }, - [], - ); - const handleCollapseDepthGuide = React.useCallback( - (message: TimelineMessage) => { - if (message.id === threadHeadId) { - collapseThreadHeadReplies(); - return; - } - - onExpandReplies(message); - }, - [collapseThreadHeadReplies, onExpandReplies, threadHeadId], - ); - const composerReplyTarget = replyTargetMessage && threadHead && replyTargetMessage.id !== threadHead.id ? { @@ -450,23 +466,6 @@ export function MessageThreadPanel({ EMPTY_THREAD_REPLIES, ); const isRepliesPending = deferredThreadReplies !== threadReplies; - const scrollTargetIsVisibleReply = React.useMemo( - () => - scrollTargetId !== null && - scrollTargetId !== threadHeadId && - deferredThreadReplies.some( - (entry) => entry.message.id === scrollTargetId, - ), - [deferredThreadReplies, scrollTargetId, threadHeadId], - ); - const isThreadHeadRepliesCollapsed = - collapsedThreadHeadId === threadHeadId && !scrollTargetIsVisibleReply; - - React.useLayoutEffect(() => { - if (scrollTargetIsVisibleReply && collapsedThreadHeadId === threadHeadId) { - setCollapsedThreadHeadId(null); - } - }, [collapsedThreadHeadId, scrollTargetIsVisibleReply, threadHeadId]); // Which of the three states the reply region paints this frame. Delegated to // a pure helper so the "don't flash empty over an incoming list" rule is @@ -475,124 +474,31 @@ export function MessageThreadPanel({ deferredThreadReplies.length, threadReplies.length, ); - const threadHeadSummary = React.useMemo(() => { - if (!threadHeadId) { - return null; - } - - return buildThreadSummaryFromVisibleEntries( - threadHeadId, - deferredThreadReplies, - ); - }, [deferredThreadReplies, threadHeadId]); - const visibleThreadHeadSummary = isThreadHeadRepliesCollapsed - ? threadHeadSummary - : null; - const threadMessages = React.useMemo( () => deferredThreadReplies.map((entry) => entry.message), [deferredThreadReplies], ); - const shouldShowThreadBranchGuides = React.useMemo( - () => hasNestedThreadBranches(deferredThreadReplies), + const flatThreadReplyEntries = React.useMemo( + () => + deferredThreadReplies.map((entry) => ({ + ...entry, + message: + entry.message.depth === 0 + ? entry.message + : { ...entry.message, depth: 0 }, + })), [deferredThreadReplies], ); - const highlightedBranch = React.useMemo(() => { - if (!hoveredCollapseBranchId) { - return null; - } - - if (hoveredCollapseBranchId === threadHeadId) { - return { - depth: 0, - endIndex: deferredThreadReplies.length - 1, - id: hoveredCollapseBranchId, - startIndex: -1, - }; - } - - const startIndex = deferredThreadReplies.findIndex( - (entry) => entry.message.id === hoveredCollapseBranchId, - ); - if (startIndex < 0) { - return null; - } - - const depth = deferredThreadReplies[startIndex].message.depth; - let endIndex = startIndex; - while ( - endIndex + 1 < deferredThreadReplies.length && - deferredThreadReplies[endIndex + 1].message.depth > depth - ) { - endIndex += 1; - } - - return { - depth, - endIndex, - id: hoveredCollapseBranchId, - startIndex, - }; - }, [deferredThreadReplies, hoveredCollapseBranchId, threadHeadId]); - const threadReplyRenderItems = React.useMemo(() => { - if (!threadHead) { - return []; - } - - const ancestorStack: { index: number; message: TimelineMessage }[] = [ - { index: -1, message: threadHead }, - ]; - - return deferredThreadReplies.map((entry, index) => { - while ( - ancestorStack.length > 0 && - ancestorStack[ancestorStack.length - 1].message.depth >= - entry.message.depth - ) { - ancestorStack.pop(); - } - - const ancestors = [...ancestorStack]; - const continuationDepths = getActiveContinuationDepths({ - ancestors, - entries: deferredThreadReplies, - index, - message: entry.message, - }); - const collapseDepthGuideAncestors = ancestors.filter((ancestor) => - continuationDepths.includes(ancestor.message.depth), - ); - const collapseDepthGuideActions: ThreadDepthGuideAction[] | undefined = - collapseDepthGuideAncestors.length > 0 - ? collapseDepthGuideAncestors.map((ancestor) => ({ - active: - hoveredCollapseBranchId === ancestor.message.id && - entry.message.depth === ancestor.message.depth + 1, - depth: ancestor.message.depth, - label: - ancestor.message.id === threadHead.id - ? "Collapse thread" - : "Collapse replies", - message: ancestor.message, - })) - : undefined; - const nextEntry = deferredThreadReplies[index + 1]; - const connectsToVisibleChild = - nextEntry != null && nextEntry.message.depth > entry.message.depth; - - if (connectsToVisibleChild && !entry.summary) { - ancestorStack.push({ index, message: entry.message }); - } - - return { - collapseDepthGuideActions, - connectsToVisibleChild, - continuationDepths, - entry, - index, - }; - }); - }, [deferredThreadReplies, hoveredCollapseBranchId, threadHead]); + const agentConversationMarkerByMessageId = React.useMemo( + () => + new Map( + (agentConversationMarkers ?? []).map((marker) => [ + marker.agentReplyId, + marker, + ]), + ), + [agentConversationMarkers], + ); const { isAtBottom, newMessageCount, onScroll, scrollToBottom } = useAnchoredScroll({ @@ -609,6 +515,20 @@ export function MessageThreadPanel({ return null; } + const threadHeadAgentConversationMarker = + agentConversationMarkerByMessageId.get(threadHead.id) ?? null; + const threadActivityRows = + threadActivityAgents.length > 0 + ? threadActivityAgents.map((agent) => ( + + )) + : null; + const threadScrollRegion = (
onUnfollowThread() : undefined } profiles={profiles} - showDepthGuides={shouldShowThreadBranchGuides} + showDepthGuides={false} videoReviewContext={threadHeadVideoReviewContext} /> + {threadHeadAgentConversationMarker ? ( + + ) : null}
@@ -665,157 +594,66 @@ export function MessageThreadPanel({ data-testid="message-thread-replies" > {repliesRenderState === "list" ? ( - visibleThreadHeadSummary ? ( -
- -
- ) : ( -
- {threadReplyRenderItems.map((item) => { - const { - collapseDepthGuideActions, - connectsToVisibleChild, - continuationDepths, - entry, - index, - } = item; - const showUnreadDivider = - index > 0 && entry.message.id === firstUnreadReplyId; - const isHighlightedBranchOwner = - highlightedBranch?.id === entry.message.id; - const isInsideHighlightedBranch = - highlightedBranch != null && - index > highlightedBranch.startIndex && - index <= highlightedBranch.endIndex; - const isDirectChildOfHighlightedBranch = - isInsideHighlightedBranch && - highlightedBranch != null && - index > highlightedBranch.startIndex && - index <= highlightedBranch.endIndex && - entry.message.depth === highlightedBranch.depth + 1; - const highlightedLineDepths = - shouldShowThreadBranchGuides && - isInsideHighlightedBranch && - highlightedBranch - ? [highlightedBranch.depth] - : undefined; - return ( -
- {showUnreadDivider ? : null} - + {flatThreadReplyEntries.map((entry, index) => { + const showUnreadDivider = + index > 0 && entry.message.id === firstUnreadReplyId; + const agentConversationMarker = + agentConversationMarkerByMessageId.get(entry.message.id) ?? + null; + + return ( +
+ {showUnreadDivider ? : null} + onSelectReplyTarget(entry.message) + : undefined + } + onToggleReaction={onToggleReaction} + profiles={profiles} + showDepthGuides={false} + /> + {agentConversationMarker ? ( + - {entry.summary ? ( - - ) : null} -
- ); - })} -
- ) - ) : repliesRenderState === "empty" ? ( + ) : null} +
+ ); + })} + {threadActivityRows} + + ) : repliesRenderState === "empty" && !threadActivityRows ? ( // Only show the empty state when the thread is GENUINELY empty. // Keying off `deferredThreadReplies` would flash "No replies" for a // frame while a non-empty list streams in on the deferred commit. @@ -827,6 +665,8 @@ export function MessageThreadPanel({ Reply in the thread to continue this branch.

+ ) : repliesRenderState === "empty" ? ( +
{threadActivityRows}
) : // "pending": deferred list is empty but the live list has content — // rows are streaming in on the deferred commit. Paint nothing rather // than flashing the empty state. @@ -888,9 +728,6 @@ export function MessageThreadPanel({ )} >
- {toolbarExtraActions ? ( -
{toolbarExtraActions}
- ) : null} {threadTypingPubkeys.length > 0 ? ( void; - onOpenThread: (message: TimelineMessage) => void; + onOpenThread?: (message: TimelineMessage) => void; showDepthGuides?: boolean; summary: TimelineThreadSummary; summaryIndentOffsetRem?: number; @@ -201,7 +201,7 @@ export function MessageThreadSummaryRow({ className="group relative isolate inline-flex h-8 w-fit max-w-full cursor-pointer items-center gap-1.5 rounded-full text-left text-xs font-medium text-muted-foreground transition-[color,opacity] before:pointer-events-none before:absolute before:-bottom-0.5 before:-left-0.5 before:-right-2 before:-top-0.5 before:-z-10 before:rounded-full before:content-[''] before:transition-[background-color,box-shadow] hover:text-foreground hover:opacity-90 hover:before:bg-background/95 hover:before:ring-1 hover:before:ring-border/70 focus-visible:outline-hidden focus-visible:before:bg-background/95 focus-visible:before:ring-1 focus-visible:before:ring-ring" data-thread-head-id={message.id} data-testid="message-thread-summary" - onClick={() => onOpenThread(message)} + onClick={() => onOpenThread?.(message)} style={{ marginLeft: threadReplyLength(marginLeftRem) }} type="button" > diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index aa8d93d95..4ef61ce25 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -7,6 +7,7 @@ import { selectTimelineBodySurface, selectTimelineIntroSurface, } from "@/features/messages/lib/timelineSnapshot"; +import type { AgentConversationMarker } from "@/features/agents/agentConversations"; import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay"; import type { TimelineMessage } from "@/features/messages/types"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; @@ -32,6 +33,7 @@ export type MessageTimelineHandle = { }; type MessageTimelineProps = { + agentConversationMarkers?: readonly AgentConversationMarker[]; agentPubkeys?: ReadonlySet; channelId?: string | null; channelIntro?: ChannelIntro | null; @@ -54,7 +56,10 @@ type MessageTimelineProps = { scrollContainerRef?: React.RefObject; /** True when the timeline has the composer overlay below it. */ hasComposerOverlay?: boolean; + contentTopPadding?: "chrome" | "compact"; isFetchingOlder?: boolean; + layoutShiftKey?: string | number | null; + messageListPlacement?: "bottom" | "top"; messageFooters?: Record; /** Map from lowercase pubkey → persona display name for bot members. */ personaLookup?: Map; @@ -66,6 +71,10 @@ type MessageTimelineProps = { onEdit?: (message: TimelineMessage) => void; onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; + onOpenAgentConversation?: ( + message: TimelineMessage, + options?: { publishMarker?: boolean }, + ) => void; onReply?: (message: TimelineMessage) => void; isSendingVideoReviewComment?: boolean; onSendVideoReviewComment?: ( @@ -95,6 +104,7 @@ type MessageTimelineProps = { unreadCount?: number; /** Per-thread unread counts keyed by thread root id. */ threadUnreadCounts?: ReadonlyMap; + trailingContent?: React.ReactNode; }; type ChannelIntroAction = { @@ -139,6 +149,7 @@ const MessageTimelineBase = React.forwardRef< MessageTimelineProps >(function MessageTimeline( { + agentConversationMarkers, agentPubkeys, channelId, channelIntro = null, @@ -151,8 +162,11 @@ const MessageTimelineBase = React.forwardRef< currentPubkey, fetchOlder, hasComposerOverlay = true, + contentTopPadding = "chrome", hasOlderMessages = true, isFetchingOlder = false, + layoutShiftKey = null, + messageListPlacement = "bottom", followThreadById, isFollowingThreadById, isMessageUnreadById, @@ -163,6 +177,7 @@ const MessageTimelineBase = React.forwardRef< onEdit, onMarkUnread, onMarkRead, + onOpenAgentConversation, onReply, channelName, channelType, @@ -179,6 +194,7 @@ const MessageTimelineBase = React.forwardRef< firstUnreadMessageId = null, unreadCount = 0, threadUnreadCounts, + trailingContent, }: MessageTimelineProps, ref, ) { @@ -535,7 +551,9 @@ const MessageTimelineBase = React.forwardRef<
{showTimelineSkeleton ? ( @@ -689,10 +711,16 @@ const MessageTimelineBase = React.forwardRef< {showMessageList ? (
+ {trailingContent}
) : null}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 79f4db0d4..3352f81d7 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -7,6 +7,7 @@ import { resolveActiveDayTimestamp, type TimelineItem, } from "@/features/messages/lib/timelineItems"; +import type { AgentConversationMarker } from "@/features/agents/agentConversations"; import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import { @@ -22,6 +23,7 @@ import { type ListVirtualizer, VirtualizedList, } from "@/shared/ui/VirtualizedList"; +import { AgentConversationMarkerRow } from "./AgentConversationMarkerRow"; import { DayDivider } from "./DayDivider"; import { ActiveDayHeader } from "./ActiveDayHeader"; import { MessageRow } from "./MessageRow"; @@ -30,6 +32,7 @@ import { SystemMessageRow } from "./SystemMessageRow"; import { UnreadDivider } from "./UnreadDivider"; type TimelineMessageListProps = { + agentConversationMarkers?: readonly AgentConversationMarker[]; agentPubkeys?: ReadonlySet; channelId?: string | null; channelName?: string; @@ -50,6 +53,10 @@ type TimelineMessageListProps = { onEdit?: (message: TimelineMessage) => void; onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; + onOpenAgentConversation?: ( + message: TimelineMessage, + options?: { publishMarker?: boolean }, + ) => void; onReply?: (message: TimelineMessage) => void; isSendingVideoReviewComment?: boolean; onSendVideoReviewComment?: ( @@ -86,6 +93,7 @@ type TimelineMessageListProps = { }; export const TimelineMessageList = React.memo(function TimelineMessageList({ + agentConversationMarkers, agentPubkeys, channelId, channelName, @@ -103,6 +111,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onEdit, onMarkUnread, onMarkRead, + onOpenAgentConversation, onReply, isSendingVideoReviewComment = false, onSendVideoReviewComment, @@ -176,6 +185,16 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ () => buildTimelineItems(entries, firstUnreadMessageId), [entries, firstUnreadMessageId], ); + const agentConversationMarkerByMessageId = React.useMemo( + () => + new Map( + (agentConversationMarkers ?? []).map((marker) => [ + marker.agentReplyId, + marker, + ]), + ), + [agentConversationMarkers], + ); const renderItem = React.useCallback( (item: TimelineItem) => { @@ -200,6 +219,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ return ( & { + agentConversationMarker?: AgentConversationMarker; entry: MainTimelineEntry; footer: React.ReactNode; isUnread?: boolean; @@ -340,6 +367,7 @@ type MessageRowItemProps = Pick< function MessageRowItem({ agentPubkeys, + agentConversationMarker, channelId, currentPubkey, entry, @@ -352,6 +380,7 @@ function MessageRowItem({ onEdit, onMarkUnread, onMarkRead, + onOpenAgentConversation, onReply, onToggleReaction, profiles, @@ -401,6 +430,7 @@ function MessageRowItem({ } onMarkRead={onMarkRead} onMarkUnread={onMarkUnread} + onOpenAgentConversation={onOpenAgentConversation} onToggleReaction={onToggleReaction} onReply={onReply} onUnfollowThread={ @@ -420,6 +450,16 @@ function MessageRowItem({ summary={summary} unreadCount={threadUnreadCounts?.get(message.id)} /> + {agentConversationMarker ? ( + + ) : null} {footer}
); @@ -440,6 +480,7 @@ function MessageRowItem({ onEdit={canEdit} onMarkRead={onMarkRead} onMarkUnread={onMarkUnread} + onOpenAgentConversation={onOpenAgentConversation} onToggleReaction={onToggleReaction} onReply={onReply} profiles={profiles} @@ -447,6 +488,15 @@ function MessageRowItem({ showDepthGuides={false} videoReviewContext={videoReviewContext} /> + {agentConversationMarker ? ( + + ) : null} {footer} ); diff --git a/desktop/src/features/messages/ui/useComposerHeightPadding.ts b/desktop/src/features/messages/ui/useComposerHeightPadding.ts index fe805ada4..b75798176 100644 --- a/desktop/src/features/messages/ui/useComposerHeightPadding.ts +++ b/desktop/src/features/messages/ui/useComposerHeightPadding.ts @@ -4,8 +4,8 @@ import { observeElementBlockSize } from "@/shared/layout/observeElementBlockSize /** * Observes the height of the composer overlay and sets the scroll - * container's `paddingBottom` to match, so content is never hidden - * behind the absolutely-positioned composer. + * container's `paddingBottom` to match, plus optional extra breathing room, so + * content is never hidden behind the absolutely-positioned composer. * * If the user is already scrolled to the bottom when padding increases, * auto-scrolls to keep them at the bottom (no visible gap). @@ -14,6 +14,7 @@ export function useComposerHeightPadding( scrollContainerRef: React.RefObject, composerRef: React.RefObject, resetKey?: unknown, + extraPaddingPx = 0, ) { React.useEffect(() => { void resetKey; @@ -35,7 +36,7 @@ export function useComposerHeightPadding( let lastPadding: number | null = null; const applyPadding = (height: number) => { - const padding = Math.ceil(height); + const padding = Math.ceil(height + extraPaddingPx); if (lastPadding !== null && Math.abs(padding - lastPadding) <= 1) { return; } @@ -60,5 +61,5 @@ export function useComposerHeightPadding( disconnect(); scrollEl.style.paddingBottom = ""; }; - }, [scrollContainerRef, composerRef, resetKey]); + }, [scrollContainerRef, composerRef, resetKey, extraPaddingPx]); } diff --git a/desktop/src/features/search/ui/SearchPromptPlaceholder.tsx b/desktop/src/features/search/ui/SearchPromptPlaceholder.tsx index 801fe7e33..a42f2a4f2 100644 --- a/desktop/src/features/search/ui/SearchPromptPlaceholder.tsx +++ b/desktop/src/features/search/ui/SearchPromptPlaceholder.tsx @@ -1,6 +1,8 @@ -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useReducedMotion } from "motion/react"; import * as React from "react"; +import { AnimatedTextSwap } from "@/shared/ui/AnimatedTextSwap"; + const SEARCH_PROMPT_WORDS = [ "everything", "a channel", @@ -9,91 +11,11 @@ const SEARCH_PROMPT_WORDS = [ "an agent", ] as const; const SEARCH_PROMPT_ROTATION_MS = 3200; -const SEARCH_PROMPT_EASE = [0.22, 1, 0.36, 1] as const; -const SEARCH_PROMPT_EXIT_EASE = [0.64, 0, 0.78, 0] as const; -const SEARCH_PROMPT_ENTER_DURATION_SECONDS = 0.54; -const SEARCH_PROMPT_EXIT_DURATION_SECONDS = 0.32; -const SEARCH_PROMPT_ENTER_STAGGER_SECONDS = 0.014; -const SEARCH_PROMPT_EXIT_STAGGER_SECONDS = 0.008; -const SEARCH_PROMPT_Y_OFFSET = "0.5rem"; -const SEARCH_PROMPT_NEGATIVE_Y_OFFSET = "-0.5rem"; -const SEARCH_PROMPT_BLUR = "0.25rem"; - -const searchPromptPhraseVariants = { - animate: { - transition: { - staggerChildren: SEARCH_PROMPT_ENTER_STAGGER_SECONDS, - }, - }, - exit: { - transition: { - staggerChildren: SEARCH_PROMPT_EXIT_STAGGER_SECONDS, - }, - }, - initial: {}, -}; - -const searchPromptCharacterVariants = { - animate: { - filter: "blur(0)", - opacity: 1, - transition: { - duration: SEARCH_PROMPT_ENTER_DURATION_SECONDS, - ease: SEARCH_PROMPT_EASE, - }, - y: 0, - }, - exit: { - filter: `blur(${SEARCH_PROMPT_BLUR})`, - opacity: 0, - transition: { - duration: SEARCH_PROMPT_EXIT_DURATION_SECONDS, - ease: SEARCH_PROMPT_EXIT_EASE, - }, - y: SEARCH_PROMPT_NEGATIVE_Y_OFFSET, - }, - initial: { - filter: `blur(${SEARCH_PROMPT_BLUR})`, - opacity: 0, - y: SEARCH_PROMPT_Y_OFFSET, - }, -}; - -function getPromptCharacters(value: string) { - const characterCounts = new Map(); - - return [...value].map((character) => { - const occurrence = characterCounts.get(character) ?? 0; - characterCounts.set(character, occurrence + 1); - - return { - character, - key: `${character}-${occurrence}`, - }; - }); -} - -function getPromptEnterTotalSeconds(characterCount: number) { - return ( - SEARCH_PROMPT_ENTER_DURATION_SECONDS + - Math.max(0, characterCount - 1) * SEARCH_PROMPT_ENTER_STAGGER_SECONDS - ); -} export function SearchPromptPlaceholder() { const shouldReduceMotion = useReducedMotion(); const [wordIndex, setWordIndex] = React.useState(0); const activeWord = SEARCH_PROMPT_WORDS[wordIndex]; - const activeCharacters = React.useMemo( - () => getPromptCharacters(activeWord), - [activeWord], - ); - const widthAnimationDurationSeconds = getPromptEnterTotalSeconds( - activeCharacters.length, - ); - const measureRef = React.useRef(null); - const pendingWordWidthRef = React.useRef(null); - const [wordWidth, setWordWidth] = React.useState(null); React.useEffect(() => { if (shouldReduceMotion) { @@ -110,31 +32,6 @@ export function SearchPromptPlaceholder() { return () => window.clearInterval(intervalId); }, [shouldReduceMotion]); - React.useLayoutEffect(() => { - if (shouldReduceMotion || activeWord.length === 0) { - return; - } - - const width = measureRef.current?.getBoundingClientRect().width; - if (typeof width === "number" && Number.isFinite(width)) { - if (wordWidth === null) { - setWordWidth(width); - } else { - pendingWordWidthRef.current = width; - } - } - }, [activeWord, shouldReduceMotion, wordWidth]); - - const handleWordExitComplete = React.useCallback(() => { - const nextWidth = pendingWordWidthRef.current; - if (nextWidth === null) { - return; - } - - pendingWordWidthRef.current = null; - setWordWidth(nextWidth); - }, []); - if (shouldReduceMotion) { return ( Search for  - - everything - - - - - + ); } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 767c9c41a..7e08ffc59 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -14,6 +14,7 @@ import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd"; import { TopbarSearch } from "@/features/search/ui/TopbarSearch"; import type { Workspace } from "@/features/workspaces/types"; +import type { AgentConversation } from "@/features/agents/agentConversations"; import { AddWorkspaceDialog } from "@/features/workspaces/ui/AddWorkspaceDialog"; import { useDeferredLoad } from "@/shared/hooks/useDeferredStartup"; import { @@ -85,6 +86,7 @@ type CreateChannelKind = "stream" | "forum"; type AppSidebarProps = { activeWorkspace: Workspace | null; + agentConversations?: AgentConversation[]; channels: Channel[]; currentPubkey?: string; fallbackDisplayName?: string; @@ -97,6 +99,7 @@ type AppSidebarProps = { profile?: Profile; selfPresenceStatus: PresenceStatus; errorMessage?: string; + selectedAgentConversationId?: string | null; selectedChannelId: string | null; selectedView: | "home" @@ -125,6 +128,7 @@ type AppSidebarProps = { templateId?: string; }) => Promise; onOpenAddWorkspace: () => void; + onHideAgentConversation?: (conversationId: string) => void; onHideDm: (channelId: string) => void; onMarkChannelUnread: (channelId: string) => void; onMarkChannelRead: ( @@ -140,6 +144,7 @@ type AppSidebarProps = { ) => void; onRemoveWorkspace: (id: string) => void; onCreateAgent: () => void; + onSelectAgentConversation?: (conversationId: string) => void; onSelectAgents: () => void; onSelectProjects: () => void; onSelectPulse: () => void; @@ -175,6 +180,7 @@ type AppSidebarProps = { export function AppSidebar({ activeWorkspace, + agentConversations = [], channels, currentPubkey, fallbackDisplayName, @@ -187,6 +193,7 @@ export function AppSidebar({ profile, selfPresenceStatus, errorMessage, + selectedAgentConversationId, selectedChannelId, selectedView, unreadChannelCounts, @@ -197,6 +204,7 @@ export function AppSidebar({ onCreateChannel, onCreateForum, onOpenAddWorkspace, + onHideAgentConversation, onHideDm, onMarkChannelUnread, onMarkChannelRead, @@ -206,6 +214,7 @@ export function AppSidebar({ onUpdateWorkspace, onRemoveWorkspace, onCreateAgent, + onSelectAgentConversation, onSelectAgents, onSelectProjects, onSelectPulse, @@ -471,6 +480,58 @@ export function AppSidebar({ () => sortDmChannelsByLabel(directMessages, dmChannelLabels), [directMessages, dmChannelLabels], ); + const agentConversationsByChannelId = React.useMemo(() => { + const byChannelId = new Map(); + + for (const conversation of agentConversations) { + const channelConversations = + byChannelId.get(conversation.channelId) ?? []; + channelConversations.push(conversation); + byChannelId.set(conversation.channelId, channelConversations); + } + + return byChannelId; + }, [agentConversations]); + const isAgentConversationActive = selectedView === "agents"; + const activeAgentConversationChannelId = React.useMemo(() => { + if (!isAgentConversationActive || !selectedAgentConversationId) { + return null; + } + + return ( + agentConversations.find( + (conversation) => conversation.id === selectedAgentConversationId, + )?.channelId ?? null + ); + }, [ + agentConversations, + isAgentConversationActive, + selectedAgentConversationId, + ]); + const displayUnreadChannelIds = React.useMemo(() => { + if ( + !activeAgentConversationChannelId || + !unreadChannelIds.has(activeAgentConversationChannelId) + ) { + return unreadChannelIds; + } + + const next = new Set(unreadChannelIds); + next.delete(activeAgentConversationChannelId); + return next; + }, [activeAgentConversationChannelId, unreadChannelIds]); + const displayUnreadChannelCounts = React.useMemo(() => { + if ( + !activeAgentConversationChannelId || + !unreadChannelCounts.has(activeAgentConversationChannelId) + ) { + return unreadChannelCounts; + } + + const next = new Map(unreadChannelCounts); + next.delete(activeAgentConversationChannelId); + return next; + }, [activeAgentConversationChannelId, unreadChannelCounts]); const sidebarLoadingShape = useSidebarLoadingShape({ activeWorkspaceId: activeWorkspace?.id, currentPubkey, @@ -488,7 +549,10 @@ export function AppSidebar({ scrollToNextBelow, unreadAboveCount, unreadBelowCount, - } = useUnreadOverflow({ scrollRef, unreadChannelIds }); + } = useUnreadOverflow({ + scrollRef, + unreadChannelIds: displayUnreadChannelIds, + }); const isCreatingAny = createDialogKind === "stream" @@ -613,7 +677,9 @@ export function AppSidebar({ {starredChannels.length > 0 ? ( - unreadChannelIds.has(c.id), + displayUnreadChannelIds.has(c.id), )} + isAgentConversationActive={isAgentConversationActive} isCollapsed={collapsedGroups.starred} isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} @@ -666,14 +734,17 @@ export function AppSidebar({ onMarkChannelRead(channel.id, channel.lastMessageAt); } }} + onHideAgentConversation={onHideAgentConversation} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} + onSelectAgentConversation={onSelectAgentConversation} onSelectChannel={onSelectChannel} onToggleCollapsed={() => toggleCollapsedGroup("starred")} selectedChannelId={selectedChannelId} + selectedAgentConversationId={selectedAgentConversationId} title="Starred" - unreadChannelCounts={unreadChannelCounts} - unreadChannelIds={unreadChannelIds} + unreadChannelCounts={displayUnreadChannelCounts} + unreadChannelIds={displayUnreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} onUnmuteChannel={onUnmuteChannel} @@ -693,26 +764,33 @@ export function AppSidebar({ > {channelSections.map((section, idx) => ( - unreadChannelIds.has(c.id), + displayUnreadChannelIds.has(c.id), ) ?? false } + isAgentConversationActive={isAgentConversationActive} isCollapsed={collapsedSections[section.id] ?? false} isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} selectedChannelId={selectedChannelId} - unreadChannelCounts={unreadChannelCounts} - unreadChannelIds={unreadChannelIds} + selectedAgentConversationId={selectedAgentConversationId} + unreadChannelCounts={displayUnreadChannelCounts} + unreadChannelIds={displayUnreadChannelIds} sections={channelSections} assignments={channelAssignments} isFirst={idx === 0} isLast={idx === channelSections.length - 1} onToggleCollapsed={() => toggleCollapsedSection(section.id)} + onHideAgentConversation={onHideAgentConversation} onSelectChannel={onSelectChannel} + onSelectAgentConversation={onSelectAgentConversation} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} onMarkSectionRead={() => { @@ -739,10 +817,12 @@ export function AppSidebar({ /> ))} 0} + hasUnread={displayUnreadChannelIds.size > 0} + isAgentConversationActive={isAgentConversationActive} isCollapsed={collapsedGroups.channels} isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} @@ -751,14 +831,17 @@ export function AppSidebar({ onBrowseClick={onBrowseChannels} onCreateClick={() => openCreateDialog("stream")} onMarkAllRead={onMarkAllChannelsRead} + onHideAgentConversation={onHideAgentConversation} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} + onSelectAgentConversation={onSelectAgentConversation} onSelectChannel={onSelectChannel} onToggleCollapsed={() => toggleCollapsedGroup("channels")} selectedChannelId={selectedChannelId} + selectedAgentConversationId={selectedAgentConversationId} title="Channels" - unreadChannelCounts={unreadChannelCounts} - unreadChannelIds={unreadChannelIds} + unreadChannelCounts={displayUnreadChannelCounts} + unreadChannelIds={displayUnreadChannelIds} sections={channelSections} assignments={channelAssignments} onAssignChannel={assignChannel} @@ -775,8 +858,10 @@ export function AppSidebar({ 0} + hasUnread={displayUnreadChannelIds.size > 0} + isAgentConversationActive={isAgentConversationActive} isCollapsed={collapsedGroups.forums} isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} @@ -784,14 +869,17 @@ export function AppSidebar({ listTestId="forum-list" onCreateClick={() => openCreateDialog("forum")} onMarkAllRead={onMarkAllChannelsRead} + onHideAgentConversation={onHideAgentConversation} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} + onSelectAgentConversation={onSelectAgentConversation} onSelectChannel={onSelectChannel} onToggleCollapsed={() => toggleCollapsedGroup("forums")} selectedChannelId={selectedChannelId} + selectedAgentConversationId={selectedAgentConversationId} title="Forums" - unreadChannelCounts={unreadChannelCounts} - unreadChannelIds={unreadChannelIds} + unreadChannelCounts={displayUnreadChannelCounts} + unreadChannelIds={displayUnreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} onUnmuteChannel={onUnmuteChannel} @@ -815,23 +903,28 @@ export function AppSidebar({ } + agentConversationsByChannelId={agentConversationsByChannelId} dmParticipantsByChannelId={dmParticipantsByChannelId} isCollapsed={collapsedGroups.directMessages} + isAgentConversationActive={isAgentConversationActive} isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} items={sortedDirectMessages} channelLabels={dmChannelLabels} + onHideAgentConversation={onHideAgentConversation} onHideDm={onHideDm} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} + onSelectAgentConversation={onSelectAgentConversation} onSelectChannel={onSelectChannel} onToggleCollapsed={() => toggleCollapsedGroup("directMessages")} presenceByChannelId={dmPresenceByChannelId} + selectedAgentConversationId={selectedAgentConversationId} selectedChannelId={selectedChannelId} testId="dm-list" title="Direct messages" - unreadChannelCounts={unreadChannelCounts} - unreadChannelIds={unreadChannelIds} + unreadChannelCounts={displayUnreadChannelCounts} + unreadChannelIds={displayUnreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} onUnmuteChannel={onUnmuteChannel} diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index fe15cfce3..b355a5e4f 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -18,6 +18,7 @@ import { StarOff, Trash2, } from "lucide-react"; +import { Fragment } from "react"; import { toast } from "sonner"; @@ -38,6 +39,7 @@ import { SidebarMenu, SidebarMenuItem, } from "@/shared/ui/sidebar"; +import type { AgentConversation } from "@/features/agents/agentConversations"; import { ChannelMenuButton } from "@/features/sidebar/ui/SidebarSection"; import { DraggableChannelRow, @@ -45,6 +47,7 @@ import { DroppableUngroupedBody, SortableSectionShell, } from "@/features/sidebar/ui/SidebarDnd"; +import { SidebarAgentConversationChildren } from "@/features/sidebar/ui/SidebarAgentConversationChildren"; import { SECTION_ACTION_VISIBILITY_CLASS, SECTION_ICON_BUTTON_CLASS, @@ -322,6 +325,7 @@ function SectionHeaderActions({ } export function ChannelGroupSection({ + agentConversationsByChannelId, browseAriaLabel, createAriaLabel, draggable, @@ -330,6 +334,7 @@ export function ChannelGroupSection({ isCollapsed, isActiveChannel, activeWorkingByChannelId, + isAgentConversationActive, items, listTestId, onBrowseClick, @@ -337,9 +342,12 @@ export function ChannelGroupSection({ onMarkAllRead, onMarkChannelRead, onMarkChannelUnread, + onHideAgentConversation, + onSelectAgentConversation, onSelectChannel, onToggleCollapsed, selectedChannelId, + selectedAgentConversationId, title, unreadChannelCounts, unreadChannelIds, @@ -356,6 +364,10 @@ export function ChannelGroupSection({ onUnstarChannel, onLeaveChannel, }: { + agentConversationsByChannelId?: ReadonlyMap< + string, + readonly AgentConversation[] + >; browseAriaLabel?: string; createAriaLabel: string; draggable?: boolean; @@ -363,6 +375,7 @@ export function ChannelGroupSection({ isCollapsed: boolean; isActiveChannel: boolean; activeWorkingByChannelId?: ReadonlyMap; + isAgentConversationActive?: boolean; items: Channel[]; listTestId: string; onBrowseClick?: () => void; @@ -372,9 +385,12 @@ export function ChannelGroupSection({ lastMessageAt: string | null | undefined, ) => void; onMarkChannelUnread: (channelId: string) => void; + onHideAgentConversation?: (conversationId: string) => void; + onSelectAgentConversation?: (conversationId: string) => void; onSelectChannel: (channelId: string) => void; onToggleCollapsed: () => void; selectedChannelId: string | null; + selectedAgentConversationId?: string | null; title: string; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; @@ -399,59 +415,75 @@ export function ChannelGroupSection({ items.length > 0 ? ( {items.map((channel) => ( - - - - {draggable ? ( - - - - ) : ( - - )} - - - - - - + + + + +
+ {draggable ? ( + + + + ) : ( + + )} +
+
+
+ + + +
+ +
))}
) : null; @@ -503,13 +535,16 @@ export function ChannelGroupSection({ } export function CustomChannelSection({ + agentConversationsByChannelId, section, channels, hasUnread, isCollapsed, isActiveChannel, activeWorkingByChannelId, + isAgentConversationActive, selectedChannelId, + selectedAgentConversationId, unreadChannelCounts, unreadChannelIds, sections, @@ -521,6 +556,8 @@ export function CustomChannelSection({ onMarkChannelRead, onMarkChannelUnread, onMarkSectionRead, + onHideAgentConversation, + onSelectAgentConversation, onAssignChannel, onUnassignChannel, onCreateSectionForChannel, @@ -536,13 +573,19 @@ export function CustomChannelSection({ onUnstarChannel, onLeaveChannel, }: { + agentConversationsByChannelId?: ReadonlyMap< + string, + readonly AgentConversation[] + >; section: ChannelSection; channels: Channel[]; hasUnread: boolean; isCollapsed: boolean; isActiveChannel: boolean; activeWorkingByChannelId?: ReadonlyMap; + isAgentConversationActive?: boolean; selectedChannelId: string | null; + selectedAgentConversationId?: string | null; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; sections: ChannelSection[]; @@ -557,6 +600,8 @@ export function CustomChannelSection({ ) => void; onMarkChannelUnread: (channelId: string) => void; onMarkSectionRead: () => void; + onHideAgentConversation?: (conversationId: string) => void; + onSelectAgentConversation?: (conversationId: string) => void; onAssignChannel: (channelId: string, sectionId: string) => void; onUnassignChannel: (channelId: string) => void; onCreateSectionForChannel: (channelId: string) => void; @@ -685,52 +730,68 @@ export function CustomChannelSection({ {channels.length > 0 ? ( {channels.map((channel) => ( - - - - - - - - - - - - + + + + +
+ + + +
+
+
+ + + +
+ +
))}
) : null} diff --git a/desktop/src/features/sidebar/ui/SidebarAgentConversationChildren.tsx b/desktop/src/features/sidebar/ui/SidebarAgentConversationChildren.tsx new file mode 100644 index 000000000..fbbbe8ec7 --- /dev/null +++ b/desktop/src/features/sidebar/ui/SidebarAgentConversationChildren.tsx @@ -0,0 +1,112 @@ +import type { AgentConversation } from "@/features/agents/agentConversations"; +import { cn } from "@/shared/lib/cn"; +import { X } from "lucide-react"; +import * as React from "react"; +import { SidebarMenuButton, SidebarMenuItem } from "@/shared/ui/sidebar"; + +const COLLAPSED_CONVERSATION_LIMIT = 4; + +type SidebarAgentConversationChildrenProps = { + channelId: string; + conversations?: readonly AgentConversation[]; + isConversationViewActive: boolean; + onHideConversation?: (conversationId: string) => void; + onSelectConversation?: (conversationId: string) => void; + selectedConversationId?: string | null; +}; + +export function SidebarAgentConversationChildren({ + channelId, + conversations, + isConversationViewActive, + onHideConversation, + onSelectConversation, + selectedConversationId, +}: SidebarAgentConversationChildrenProps) { + const [isExpanded, setIsExpanded] = React.useState(false); + + if (!conversations || conversations.length === 0) { + return null; + } + + const hasOverflow = conversations.length > COLLAPSED_CONVERSATION_LIMIT; + const visibleConversations = isExpanded + ? conversations + : conversations.slice(0, COLLAPSED_CONVERSATION_LIMIT); + const toggleLabel = isExpanded ? "Show less" : "Show more"; + + return ( + <> + {visibleConversations.map((conversation) => { + const isActive = + isConversationViewActive && + selectedConversationId === conversation.id; + + return ( + +
+ { + event.stopPropagation(); + onSelectConversation?.(conversation.id); + }} + tooltip={conversation.title} + type="button" + > + + {conversation.title} + + + {onHideConversation ? ( + + ) : null} +
+
+ ); + })} + {hasOverflow ? ( + + { + event.stopPropagation(); + setIsExpanded((current) => !current); + }} + tooltip={toggleLabel} + type="button" + > + {toggleLabel} + + + ) : null} + + ); +} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 723e58f48..9ee2ffcf3 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -1,3 +1,4 @@ +import { Fragment } from "react"; import type * as React from "react"; import { BellOff, @@ -20,7 +21,9 @@ import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurn import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel"; import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge"; +import type { AgentConversation } from "@/features/agents/agentConversations"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { SidebarAgentConversationChildren } from "@/features/sidebar/ui/SidebarAgentConversationChildren"; import type { Channel, PresenceStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { useNow } from "@/shared/lib/useNow"; @@ -314,21 +317,26 @@ export function ChannelMenuButton({ export function SidebarSection({ action, activeWorkingByChannelId, + agentConversationsByChannelId, dmParticipantsByChannelId, emptyState, items, channelLabels, isCollapsed, isActiveChannel, + isAgentConversationActive, presenceByChannelId, + selectedAgentConversationId, selectedChannelId, title, testId, unreadChannelCounts, unreadChannelIds, + onHideAgentConversation, onHideDm, onMarkChannelRead, onMarkChannelUnread, + onSelectAgentConversation, onSelectChannel, onToggleCollapsed, mutedChannelIds, @@ -337,13 +345,19 @@ export function SidebarSection({ }: { action?: React.ReactNode; activeWorkingByChannelId?: ReadonlyMap; + agentConversationsByChannelId?: ReadonlyMap< + string, + readonly AgentConversation[] + >; dmParticipantsByChannelId?: Record; emptyState?: React.ReactNode; items: Channel[]; channelLabels?: Record; isCollapsed?: boolean; isActiveChannel: boolean; + isAgentConversationActive?: boolean; presenceByChannelId?: Record; + selectedAgentConversationId?: string | null; selectedChannelId: string | null; title: string; testId: string; @@ -354,7 +368,9 @@ export function SidebarSection({ channelId: string, lastMessageAt: string | null | undefined, ) => void; + onHideAgentConversation?: (conversationId: string) => void; onMarkChannelUnread?: (channelId: string) => void; + onSelectAgentConversation?: (conversationId: string) => void; onSelectChannel: (channelId: string) => void; onToggleCollapsed?: () => void; mutedChannelIds?: ReadonlySet; @@ -406,74 +422,94 @@ export function SidebarSection({ key={onMarkChannelUnread ? undefined : channel.id} className="group/menu-item" > - - {channel.channelType === "dm" && - unreadChannelIds.has(channel.id) && - !(isActiveChannel && selectedChannelId === channel.id) ? ( - + - ) : null} - {channel.channelType === "dm" && onHideDm ? ( - - ) : null} + {channel.channelType === "dm" && + unreadChannelIds.has(channel.id) && + !(isActiveChannel && selectedChannelId === channel.id) ? ( + + ) : null} + {channel.channelType === "dm" && onHideDm ? ( + + ) : null} +
); // The shared menu always renders copy actions, so every row // gets a context menu regardless of read/mute availability. return ( - - {menuItem} - - - - + + + + {menuItem} + + + + + + + ); })} diff --git a/desktop/src/shared/api/relayChannelFilters.ts b/desktop/src/shared/api/relayChannelFilters.ts index d0c7e7938..a00e02945 100644 --- a/desktop/src/shared/api/relayChannelFilters.ts +++ b/desktop/src/shared/api/relayChannelFilters.ts @@ -2,6 +2,7 @@ import { CHANNEL_AUX_EVENT_KINDS, CHANNEL_EVENT_KINDS, CHANNEL_TIMELINE_CONTENT_KINDS, + CHANNEL_TIMELINE_STATE_KINDS, HOME_MENTION_EVENT_KINDS, KIND_DELETION, KIND_NIP29_DELETE_EVENT, @@ -41,9 +42,9 @@ export function buildChannelFilter( } /** - * History filter for cold-load and scrollback: message kinds *only*, so the - * `limit` budget buys visible message depth. Auxiliary events (reactions, - * edits, deletions) are backfilled separately by `#e` reference via + * History filter for cold-load and scrollback: message kinds plus lightweight + * timeline state markers. Auxiliary events (reactions, edits, deletions) are + * backfilled separately by `#e` reference via * {@link buildChannelStructuralAuxFilter} and * {@link buildChannelReactionAuxFilter}, and arrive for future messages * through the live subscription ({@link buildChannelFilter}, which keeps the @@ -55,7 +56,7 @@ export function buildChannelHistoryFilter( until?: number, ): RelaySubscriptionFilter { const filter: RelaySubscriptionFilter = { - kinds: [...CHANNEL_TIMELINE_CONTENT_KINDS], + kinds: [...CHANNEL_TIMELINE_CONTENT_KINDS, ...CHANNEL_TIMELINE_STATE_KINDS], "#h": [channelId], limit, }; diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 15199b8dc..d95f00ecc 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -6,7 +6,10 @@ export const KIND_STREAM_MESSAGE = 9; export const KIND_NIP29_DELETE_EVENT = 9005; export const KIND_STREAM_MESSAGE_V2 = 40002; export const KIND_STREAM_MESSAGE_EDIT = 40003; +export const KIND_STREAM_MESSAGE_PINNED = 40004; export const KIND_STREAM_MESSAGE_DIFF = 40008; +export const KIND_AGENT_CONVERSATION = 40010; +export const KIND_AGENT_CONVERSATION_COMPAT = KIND_STREAM_MESSAGE_PINNED; export const KIND_REMINDER = 40007; export const KIND_SYSTEM_MESSAGE = 40099; export const KIND_JOB_REQUEST = 43001; @@ -67,10 +70,19 @@ export const CHANNEL_EVENT_KINDS = [ ...CHANNEL_MESSAGE_EVENT_KINDS, 40001, // legacy: pre-migration stream messages KIND_STREAM_MESSAGE_EDIT, // 40003 — message edits + KIND_AGENT_CONVERSATION_COMPAT, // 40004 — staging-compatible focused agent conversation marker KIND_STREAM_MESSAGE_DIFF, // 40008 — message diffs + KIND_AGENT_CONVERSATION, // 40010 — focused agent conversation marker KIND_SYSTEM_MESSAGE, // 40099 — system messages (join, leave, etc.) ] as const; +// Stored channel-scoped state that should be fetched with timeline history but +// should not render as a message row or count against unread message tallies. +export const CHANNEL_TIMELINE_STATE_KINDS = [ + KIND_AGENT_CONVERSATION_COMPAT, // 40004 — staging-compatible focused agent conversation marker + KIND_AGENT_CONVERSATION, // 40010 — focused agent conversation marker +] as const; + // Auxiliary (non-row) timeline kinds: events that overlay onto or hide an // existing message rather than rendering their own row — reactions, edits, and // deletions. History fetches request the visible content kinds only, so the diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index e41685688..0b5d2c070 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -119,21 +119,25 @@ } .buzz-shimmer { - --buzz-shimmer-duration: 2000ms; + --buzz-shimmer-dur: 2000ms; + --buzz-shimmer-base: #7c7c7c; + --buzz-shimmer-highlight: #0d0d0d; --buzz-shimmer-band: 400%; - --buzz-shimmer-highlight: color-mix( - in srgb, - hsl(var(--foreground)) 82%, - hsl(var(--muted-foreground)) 18% - ); + --buzz-shimmer-ease: linear; - color: hsl(var(--muted-foreground)); + color: var(--buzz-shimmer-base); display: inline-block; position: relative; } +.dark .buzz-shimmer { + --buzz-shimmer-base: #8f96a3; + --buzz-shimmer-highlight: #f8fafc; +} + .buzz-shimmer::before { - animation: buzz-shimmer var(--buzz-shimmer-duration) linear infinite; + animation: buzz-shimmer var(--buzz-shimmer-dur) var(--buzz-shimmer-ease) + infinite; background-clip: text; background-image: linear-gradient( 90deg, @@ -146,7 +150,7 @@ background-repeat: no-repeat; background-size: var(--buzz-shimmer-band) 100%; color: transparent; - content: attr(data-shimmer-text); + content: attr(data-text); inset: 0; pointer-events: none; position: absolute; @@ -901,6 +905,27 @@ --agent-icon-gap: 0.125rem; } +.message-markdown ul { + list-style-type: disc; + margin: 0.25rem 0; + padding-left: 1.5rem; +} + +.message-markdown ol { + list-style-type: decimal; + margin: 0.25rem 0; + padding-left: 1.5rem; +} + +.message-markdown li { + margin: 0.125rem 0; + padding-left: 0.125rem; +} + +.message-markdown li > p { + display: inline; +} + .message-markdown .mention-chip, .message-markdown .inline-code-chip, .message-markdown :not(pre) > code { diff --git a/desktop/src/shared/ui/AnimatedTextSwap.tsx b/desktop/src/shared/ui/AnimatedTextSwap.tsx new file mode 100644 index 000000000..2971b5a95 --- /dev/null +++ b/desktop/src/shared/ui/AnimatedTextSwap.tsx @@ -0,0 +1,191 @@ +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; + +const ANIMATED_TEXT_SWAP_EASE = [0.22, 1, 0.36, 1] as const; +const ANIMATED_TEXT_SWAP_EXIT_EASE = [0.64, 0, 0.78, 0] as const; +const ANIMATED_TEXT_SWAP_ENTER_DURATION_SECONDS = 0.54; +const ANIMATED_TEXT_SWAP_EXIT_DURATION_SECONDS = 0.32; +const ANIMATED_TEXT_SWAP_ENTER_STAGGER_SECONDS = 0.014; +const ANIMATED_TEXT_SWAP_EXIT_STAGGER_SECONDS = 0.008; +const ANIMATED_TEXT_SWAP_Y_OFFSET = "0.5rem"; +const ANIMATED_TEXT_SWAP_NEGATIVE_Y_OFFSET = "-0.5rem"; +const ANIMATED_TEXT_SWAP_BLUR = "0.25rem"; + +const animatedTextSwapPhraseVariants = { + animate: { + transition: { + staggerChildren: ANIMATED_TEXT_SWAP_ENTER_STAGGER_SECONDS, + }, + }, + exit: { + transition: { + staggerChildren: ANIMATED_TEXT_SWAP_EXIT_STAGGER_SECONDS, + }, + }, + initial: {}, +}; + +const animatedTextSwapCharacterVariants = { + animate: { + filter: "blur(0)", + opacity: 1, + transition: { + duration: ANIMATED_TEXT_SWAP_ENTER_DURATION_SECONDS, + ease: ANIMATED_TEXT_SWAP_EASE, + }, + y: 0, + }, + exit: { + filter: `blur(${ANIMATED_TEXT_SWAP_BLUR})`, + opacity: 0, + transition: { + duration: ANIMATED_TEXT_SWAP_EXIT_DURATION_SECONDS, + ease: ANIMATED_TEXT_SWAP_EXIT_EASE, + }, + y: ANIMATED_TEXT_SWAP_NEGATIVE_Y_OFFSET, + }, + initial: { + filter: `blur(${ANIMATED_TEXT_SWAP_BLUR})`, + opacity: 0, + y: ANIMATED_TEXT_SWAP_Y_OFFSET, + }, +}; + +function getAnimatedTextCharacters(value: string) { + const characterCounts = new Map(); + + return [...value].map((character) => { + const occurrence = characterCounts.get(character) ?? 0; + characterCounts.set(character, occurrence + 1); + + return { + character, + key: `${character}-${occurrence}`, + }; + }); +} + +function getAnimatedTextEnterTotalSeconds(characterCount: number) { + return ( + ANIMATED_TEXT_SWAP_ENTER_DURATION_SECONDS + + Math.max(0, characterCount - 1) * ANIMATED_TEXT_SWAP_ENTER_STAGGER_SECONDS + ); +} + +type AnimatedTextSwapProps = { + ariaHidden?: boolean; + characterTestId?: string; + className?: string; + textClassName?: string; + value: string; +}; + +export function AnimatedTextSwap({ + ariaHidden = false, + characterTestId, + className, + textClassName, + value, +}: AnimatedTextSwapProps) { + const shouldReduceMotion = useReducedMotion(); + const activeCharacters = React.useMemo( + () => getAnimatedTextCharacters(value), + [value], + ); + const widthAnimationDurationSeconds = getAnimatedTextEnterTotalSeconds( + activeCharacters.length, + ); + const measureRef = React.useRef(null); + const pendingTextWidthRef = React.useRef(null); + const [textWidth, setTextWidth] = React.useState(null); + + React.useLayoutEffect(() => { + if (shouldReduceMotion || value.length === 0) { + return; + } + + const width = measureRef.current?.getBoundingClientRect().width; + if (typeof width === "number" && Number.isFinite(width)) { + if (textWidth === null) { + setTextWidth(width); + } else { + pendingTextWidthRef.current = width; + } + } + }, [shouldReduceMotion, textWidth, value]); + + const handleTextExitComplete = React.useCallback(() => { + const nextWidth = pendingTextWidthRef.current; + if (nextWidth === null) { + return; + } + + pendingTextWidthRef.current = null; + setTextWidth(nextWidth); + }, []); + + if (shouldReduceMotion) { + return ( + + {value} + + ); + } + + return ( + + {ariaHidden ? null : {value}} + + + + + + ); +} diff --git a/desktop/src/shared/ui/Shimmer.tsx b/desktop/src/shared/ui/Shimmer.tsx index f9b543ef0..957798342 100644 --- a/desktop/src/shared/ui/Shimmer.tsx +++ b/desktop/src/shared/ui/Shimmer.tsx @@ -7,10 +7,7 @@ type ShimmerProps = { export function Shimmer({ children, className }: ShimmerProps) { return ( - + {children} ); From 9bd15a9f2b7a522a886403ed1c458df5563706b4 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 27 Jun 2026 09:56:46 +0100 Subject: [PATCH 2/8] Fix task conversation foundation issues --- .../agents/agentConversations.test.mjs | 78 +++++++++++++++++++ .../src/features/agents/agentConversations.ts | 39 +++++++--- .../src/features/channels/ui/ChannelPane.tsx | 11 --- .../messages/lib/timelineItems.test.mjs | 21 +++++ .../features/messages/lib/timelineItems.ts | 7 +- .../features/messages/ui/MessageTimeline.tsx | 12 ++- .../messages/ui/TimelineMessageList.tsx | 9 ++- .../src/features/sidebar/ui/AppSidebar.tsx | 41 +--------- 8 files changed, 152 insertions(+), 66 deletions(-) diff --git a/desktop/src/features/agents/agentConversations.test.mjs b/desktop/src/features/agents/agentConversations.test.mjs index 99f32d7cc..8cfa2180b 100644 --- a/desktop/src/features/agents/agentConversations.test.mjs +++ b/desktop/src/features/agents/agentConversations.test.mjs @@ -407,3 +407,81 @@ test("continued conversation marker hides same-second messages after the anchor" assert.deepEqual([...hiddenIds], ["after"]); }); + +test("continued conversation marker with a missing anchor does not hide thread messages", () => { + const root = message({ + body: "Can you look into the data model?", + createdAt: 1, + id: "root", + }); + const reply = message({ + body: "One note before opening.", + createdAt: 3, + id: "reply", + }); + const marker = parseAgentConversationMarker( + markerEvent({ content: { agentReplyId: "missing-reply" }, createdAt: 4 }), + ); + + const hiddenIds = getHiddenAgentConversationMessageIds( + [root, reply], + marker ? [marker] : [], + ); + + assert.deepEqual([...hiddenIds], []); +}); + +test("continued conversation markers keep later task anchors visible", () => { + const root = message({ + body: "Can you look into the data model?", + createdAt: 1, + id: "root", + }); + const firstAnchor = message({ + body: "I'll look into it.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const hiddenReply = message({ + body: "This should live in the first task.", + createdAt: 3, + id: "hidden", + }); + const secondAnchor = message({ + body: "Let's split this into another task.", + createdAt: 4, + id: "second-anchor", + pubkey: "agent", + }); + const laterReply = message({ + body: "This should also be hidden.", + createdAt: 5, + id: "later", + }); + + const firstMarker = parseAgentConversationMarker( + markerEvent({ content: { startedAt: 2 }, createdAt: 2 }), + ); + const secondMarker = parseAgentConversationMarker({ + ...markerEvent({ + content: { agentReplyId: "second-anchor", startedAt: 4 }, + createdAt: 4, + id: "second-marker", + }), + tags: [ + ["h", "channel"], + ["e", "root", "", "root"], + ["e", "second-anchor", "", "agent-reply"], + ["p", "agent"], + ["title", "Second task"], + ], + }); + + const hiddenIds = getHiddenAgentConversationMessageIds( + [root, firstAnchor, hiddenReply, secondAnchor, laterReply], + [firstMarker, secondMarker].filter(Boolean), + ); + + assert.deepEqual([...hiddenIds], ["hidden", "later"]); +}); diff --git a/desktop/src/features/agents/agentConversations.ts b/desktop/src/features/agents/agentConversations.ts index cf93fed4a..94fe37e2e 100644 --- a/desktop/src/features/agents/agentConversations.ts +++ b/desktop/src/features/agents/agentConversations.ts @@ -626,26 +626,42 @@ export function getHiddenAgentConversationMessageIds( const messageIndexById = new Map( orderedMessages.map(({ message }, index) => [message.id, index]), ); + const messageById = new Map( + orderedMessages.map(({ message }) => [message.id, message]), + ); + const anchorMessageIdsByThreadRootId = new Map>(); const cutoffByThreadRootId = new Map< string, { - anchorIndex: number | null; - anchorMessageId: string; + anchorIndex: number; startedAt: number; } >(); for (const marker of markers) { + const anchorMessage = messageById.get(marker.agentReplyId); + const anchorIndex = messageIndexById.get(marker.agentReplyId); + if (!anchorMessage || anchorIndex === undefined) { + continue; + } + + const anchorThreadRootId = + anchorMessage.rootId ?? anchorMessage.parentId ?? anchorMessage.id; + if (anchorThreadRootId !== marker.threadRootId) { + continue; + } + + const anchorMessageIds = + anchorMessageIdsByThreadRootId.get(marker.threadRootId) ?? new Set(); + anchorMessageIds.add(marker.agentReplyId); + anchorMessageIdsByThreadRootId.set(marker.threadRootId, anchorMessageIds); + const current = cutoffByThreadRootId.get(marker.threadRootId); const candidate = { - anchorIndex: messageIndexById.get(marker.agentReplyId) ?? null, - anchorMessageId: marker.agentReplyId, + anchorIndex, startedAt: marker.startedAt, }; const isEarlier = - current === undefined || - (candidate.anchorIndex !== null && current.anchorIndex !== null - ? candidate.anchorIndex < current.anchorIndex - : candidate.startedAt < current.startedAt); + current === undefined || candidate.anchorIndex < current.anchorIndex; if (isEarlier) { cutoffByThreadRootId.set(marker.threadRootId, candidate); } @@ -659,12 +675,15 @@ export function getHiddenAgentConversationMessageIds( } const cutoff = cutoffByThreadRootId.get(threadRootId); - if (cutoff === undefined || message.id === cutoff.anchorMessageId) { + if ( + cutoff === undefined || + anchorMessageIdsByThreadRootId.get(threadRootId)?.has(message.id) + ) { continue; } const messageIndex = messageIndexById.get(message.id); - if (cutoff.anchorIndex !== null && messageIndex !== undefined) { + if (messageIndex !== undefined) { if (messageIndex > cutoff.anchorIndex) { hiddenIds.add(message.id); } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 8aa8e46b3..a908e6ac3 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -361,17 +361,6 @@ export const ChannelPane = React.memo(function ChannelPane({ return true; }, [findLastOwnEditable, messages, onEdit]); - const handleEditLastOwnThreadMessage = React.useCallback((): boolean => { - if (!onEdit) return false; - const scope: TimelineMessage[] = []; - if (threadHeadMessage) scope.push(threadHeadMessage); - for (const entry of threadMessages) scope.push(entry.message); - const target = findLastOwnEditable(scope); - if (!target) return false; - onEdit(target); - return true; - }, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]); - const isComposerDisabled = !activeChannel?.isMember || activeChannel.archivedAt !== null || diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs index dcb8678aa..0f9f3dd8e 100644 --- a/desktop/src/features/messages/lib/timelineItems.test.mjs +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -90,6 +90,27 @@ test("buildTimelineItems: system messages flatten to a 'system' item", () => { assert.deepEqual(kinds(items), ["day-divider", "message", "system"]); }); +test("buildTimelineItems: can omit only the initial day divider", () => { + const entries = [ + entry({ id: "d1a", createdAt: dayAt(2026, 6, 13) }), + entry({ id: "d1b", createdAt: dayAt(2026, 6, 13, 13) }), + entry({ id: "d2a", createdAt: dayAt(2026, 6, 14) }), + ]; + const { items, indexByMessageId } = buildTimelineItems(entries, null, { + showInitialDayDivider: false, + }); + + assert.deepEqual(kinds(items), [ + "message", + "message", + "day-divider", + "message", + ]); + assert.equal(indexByMessageId.get("d1a"), 0); + assert.equal(indexByMessageId.get("d1b"), 1); + assert.equal(indexByMessageId.get("d2a"), 3); +}); + test("buildTimelineItems: empty entries produce no items and an empty map", () => { const { items, indexByMessageId } = buildTimelineItems([], null); assert.equal(items.length, 0); diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index a28e08b8b..27d7fd67e 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -36,6 +36,10 @@ export type TimelineItemsResult = { indexByMessageId: Map; }; +type BuildTimelineItemsOptions = { + showInitialDayDivider?: boolean; +}; + /** Stable per-item key, unique across the flattened stream. */ export function getTimelineItemKey(item: TimelineItem): string { return item.key; @@ -79,6 +83,7 @@ function entryRenderKey(entry: MainTimelineEntry): string { export function buildTimelineItems( entries: MainTimelineEntry[], firstUnreadMessageId: string | null, + { showInitialDayDivider = true }: BuildTimelineItemsOptions = {}, ): TimelineItemsResult { const items: TimelineItem[] = []; const indexByMessageId = new Map(); @@ -99,7 +104,7 @@ export function buildTimelineItems( const renderKey = entryRenderKey(entry); const dayBoundary = dayBoundariesByStartIndex.get(i); - if (dayBoundary) { + if (dayBoundary && (showInitialDayDivider || i !== 0)) { items.push({ kind: "day-divider", key: dayBoundary.key, diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 4ef61ce25..b497f1adc 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -96,6 +96,7 @@ type MessageTimelineProps = { searchMatchingMessageIds?: Set; /** The current find-in-channel query string. */ searchQuery?: string; + showInitialDayDivider?: boolean; targetMessageId?: string | null; onTargetReached?: (messageId: string) => void; /** Event id of the oldest unread top-level message at channel open, or null. */ @@ -189,6 +190,7 @@ const MessageTimelineBase = React.forwardRef< searchActiveMessageId = null, searchMatchingMessageIds, searchQuery, + showInitialDayDivider = true, targetMessageId = null, onTargetReached, firstUnreadMessageId = null, @@ -275,8 +277,11 @@ const MessageTimelineBase = React.forwardRef< [mainEntries, deferredMessages, messages], ); const timelineItems = React.useMemo( - () => buildTimelineItems(deferredEntries, firstUnreadMessageId), - [deferredEntries, firstUnreadMessageId], + () => + buildTimelineItems(deferredEntries, firstUnreadMessageId, { + showInitialDayDivider, + }), + [deferredEntries, firstUnreadMessageId, showInitialDayDivider], ); const virtualizerOption = React.useMemo( () => @@ -303,7 +308,7 @@ const MessageTimelineBase = React.forwardRef< // scrollTop into the current scroll element during navigation; reusing the // same node across channel routes can leave the newly-loaded message list // painted at a stale offset until the user's next scroll event forces layout. - const scrollContainerDomKey = channelId ?? "none"; + const scrollContainerDomKey = `${channelId ?? "none"}:${layoutShiftKey ?? "none"}`; const timelineBodySurface = selectTimelineBodySurface({ deferredCount: deferredMessages.length, @@ -751,6 +756,7 @@ const MessageTimelineBase = React.forwardRef< searchActiveMessageId={searchActiveMessageId} searchMatchingMessageIds={searchMatchingMessageIds} searchQuery={searchQuery} + showInitialDayDivider={showInitialDayDivider} threadUnreadCounts={threadUnreadCounts} unfollowThreadById={unfollowThreadById} scrollContainerRef={scrollContainerRef} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 3352f81d7..a57c5d38e 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -81,6 +81,7 @@ type TimelineMessageListProps = { searchMatchingMessageIds?: Set; /** The current find-in-channel query string. */ searchQuery?: string; + showInitialDayDivider?: boolean; /** Per-thread unread counts keyed by thread root id. */ threadUnreadCounts?: ReadonlyMap; /** Caller-owned scroll container the virtualizer measures and scrolls. */ @@ -120,6 +121,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ searchActiveMessageId = null, searchMatchingMessageIds, searchQuery, + showInitialDayDivider = true, threadUnreadCounts, unfollowThreadById, scrollContainerRef, @@ -182,8 +184,11 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ // diverging deps would let the map go stale and scroll deep-links to the wrong // row — the exact failure virtualization risks. const itemsResult = React.useMemo( - () => buildTimelineItems(entries, firstUnreadMessageId), - [entries, firstUnreadMessageId], + () => + buildTimelineItems(entries, firstUnreadMessageId, { + showInitialDayDivider, + }), + [entries, firstUnreadMessageId, showInitialDayDivider], ); const agentConversationMarkerByMessageId = React.useMemo( () => diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 7e08ffc59..51f4b4e49 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -493,45 +493,8 @@ export function AppSidebar({ return byChannelId; }, [agentConversations]); const isAgentConversationActive = selectedView === "agents"; - const activeAgentConversationChannelId = React.useMemo(() => { - if (!isAgentConversationActive || !selectedAgentConversationId) { - return null; - } - - return ( - agentConversations.find( - (conversation) => conversation.id === selectedAgentConversationId, - )?.channelId ?? null - ); - }, [ - agentConversations, - isAgentConversationActive, - selectedAgentConversationId, - ]); - const displayUnreadChannelIds = React.useMemo(() => { - if ( - !activeAgentConversationChannelId || - !unreadChannelIds.has(activeAgentConversationChannelId) - ) { - return unreadChannelIds; - } - - const next = new Set(unreadChannelIds); - next.delete(activeAgentConversationChannelId); - return next; - }, [activeAgentConversationChannelId, unreadChannelIds]); - const displayUnreadChannelCounts = React.useMemo(() => { - if ( - !activeAgentConversationChannelId || - !unreadChannelCounts.has(activeAgentConversationChannelId) - ) { - return unreadChannelCounts; - } - - const next = new Map(unreadChannelCounts); - next.delete(activeAgentConversationChannelId); - return next; - }, [activeAgentConversationChannelId, unreadChannelCounts]); + const displayUnreadChannelIds = unreadChannelIds; + const displayUnreadChannelCounts = unreadChannelCounts; const sidebarLoadingShape = useSidebarLoadingShape({ activeWorkspaceId: activeWorkspace?.id, currentPubkey, From ff823eb88373c269c56e0cbcf9f98310d7f21963 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 27 Jun 2026 11:14:57 +0100 Subject: [PATCH 3/8] Fix flattened thread scroll tests --- .../messages/ui/MessageThreadPanel.tsx | 38 ++- .../features/messages/ui/MessageTimeline.tsx | 8 + desktop/tests/e2e/messaging.spec.ts | 14 +- .../e2e/thread-reply-anchor-roleplay.spec.ts | 30 +-- desktop/tests/e2e/thread-unread.spec.ts | 251 ++++++------------ 5 files changed, 132 insertions(+), 209 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 54f541fe1..70aa54973 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -323,7 +323,7 @@ export function MessageThreadPanelSkeleton({ const threadBody = (
{ + scrollToBottomOnNextUpdate(); + return onSend(content, mentionPubkeys, mediaTags); + }, + [onSend, scrollToBottomOnNextUpdate], + ); if (!threadHead) { return null; @@ -532,7 +544,7 @@ export function MessageThreadPanel({ const threadScrollRegion = (
{ + const container = scrollContainerRef.current; + if (!container) return; + container.scrollTo({ + top: container.scrollHeight, + behavior: "auto", + }); + }); return true; } : () => false; diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 5835ef82e..453fdaebb 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -667,14 +667,10 @@ test("opens a single-level thread panel with inline expansion", async ({ .first(); await expect(nestedReplyFromBobRow).toBeVisible(); - const firstReplySummaryRow = threadReplies.locator( - `[data-testid="message-thread-summary"][data-thread-head-id="${firstReplyId}"]`, - ); - await expect(firstReplySummaryRow).toHaveCount(0); const firstReplyBranchRail = threadReplies.locator( `[data-testid="thread-collapse-rail"][data-thread-head-id="${firstReplyId}"]`, ); - await expect(firstReplyBranchRail).toHaveCount(1); + await expect(firstReplyBranchRail).toHaveCount(0); await expect(rootSummaryRow).toContainText("18 replies"); await expect( @@ -693,18 +689,14 @@ test("opens a single-level thread panel with inline expansion", async ({ .toBe("1,2"); await expectThreadReplyUnobscured(nestedReplyRow); - - await firstReplyBranchRail.click(); - await expect(firstReplySummaryRow).toHaveCount(1); - await expect(firstReplySummaryRow).toContainText("2 replies"); await expect( threadReplies.getByTestId("message-row").filter({ hasText: nestedReply }), - ).toHaveCount(0); + ).toHaveCount(1); await expect( threadReplies .getByTestId("message-row") .filter({ hasText: nestedReplyFromBob }), - ).toHaveCount(0); + ).toHaveCount(1); }); test("thread panel width uses session storage and reset handle", async ({ diff --git a/desktop/tests/e2e/thread-reply-anchor-roleplay.spec.ts b/desktop/tests/e2e/thread-reply-anchor-roleplay.spec.ts index b4a61c5da..5855bfe4c 100644 --- a/desktop/tests/e2e/thread-reply-anchor-roleplay.spec.ts +++ b/desktop/tests/e2e/thread-reply-anchor-roleplay.spec.ts @@ -136,18 +136,6 @@ async function openThread(page: import("@playwright/test").Page) { await expect(page.getByTestId("message-thread-panel")).toBeVisible(); } -async function expandReply( - page: import("@playwright/test").Page, - replyId: string, -) { - const replies = page - .getByTestId("message-thread-replies") - .getByTestId("message-row"); - const before = await replies.count(); - await page.locator(`[data-thread-head-id="${replyId}"]`).click(); - await expect.poll(() => replies.count()).toBeGreaterThan(before); -} - async function screenshotThreadPanel( page: import("@playwright/test").Page, path: string, @@ -160,7 +148,7 @@ async function screenshotThreadPanel( } test.describe("thread reply anchor A/B roleplay screenshots", () => { - test("01-baseline-human-reply-nests-agent-at-depth-2", async ({ page }) => { + test("01-baseline-human-reply-flattens-agent-in-panel", async ({ page }) => { await setupRoleplayChannel(page); const now = Math.floor(Date.now() / 1000); @@ -186,8 +174,8 @@ test.describe("thread reply anchor A/B roleplay screenshots", () => { }, ); - // Baseline queue.rs anchored the agent response to the triggering human - // reply, producing depth 2 under Nora's message. + // Even when an older agent response is anchored to the triggering human + // reply, the thread panel now renders the whole thread as a flat list. await emitMockMessage( page, CHANNEL, @@ -201,15 +189,14 @@ test.describe("thread reply anchor A/B roleplay screenshots", () => { ); await openThread(page); - await expandReply(page, humanReply.id); await expect(page.getByText("Nora: adding context")).toBeVisible(); await expect(page.getByText("Pinky: Got it")).toBeVisible(); await expect( page.getByTestId("message-thread-replies").getByTestId("message-row"), ).toHaveCount(2); - await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(1); + await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(0); - await screenshotThreadPanel(page, `${SHOTS}/01-baseline-depth-2.png`); + await screenshotThreadPanel(page, `${SHOTS}/01-baseline-flat.png`); }); test("02-patched-human-reply-flattens-agent-at-root", async ({ page }) => { @@ -300,7 +287,7 @@ test.describe("thread reply anchor A/B roleplay screenshots", () => { await screenshotThreadPanel(page, `${SHOTS}/03-top-level-human-root.png`); }); - test("04-agent-only-branch-keeps-deeper-nesting", async ({ page }) => { + test("04-agent-only-branch-flattens-in-panel", async ({ page }) => { await setupRoleplayChannel(page); const now = Math.floor(Date.now() / 1000); @@ -338,14 +325,13 @@ test.describe("thread reply anchor A/B roleplay screenshots", () => { ); await openThread(page); - await expandReply(page, brainReply.id); await expect(page.getByText("Brain: Check the anchor")).toBeVisible(); await expect(page.getByText("Pinky: Good catch")).toBeVisible(); await expect( page.getByTestId("message-thread-replies").getByTestId("message-row"), ).toHaveCount(2); - await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(1); + await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(0); - await screenshotThreadPanel(page, `${SHOTS}/04-agent-only-nested.png`); + await screenshotThreadPanel(page, `${SHOTS}/04-agent-only-flat.png`); }); }); diff --git a/desktop/tests/e2e/thread-unread.spec.ts b/desktop/tests/e2e/thread-unread.spec.ts index 5be42ff12..6d0810b46 100644 --- a/desktop/tests/e2e/thread-unread.spec.ts +++ b/desktop/tests/e2e/thread-unread.spec.ts @@ -91,24 +91,6 @@ function unreadTimestamp() { // dot without the user having to participate in the thread first. const SELF_PUBKEY = "deadbeef".repeat(8); -// Nested replies are collapsed behind a summary row that carries the parent's -// id (data-thread-head-id). Expanding one level renders that reply's direct -// children, so the rendered count MUST grow after the click — asserting that -// ties the test to genuine rendered depth: a no-op expansion fails here rather -// than passing silently. A level can reveal several children at once (a -// branch), so the check is "grew", not "grew by one". -async function expandReply( - page: import("@playwright/test").Page, - replyId: string, -) { - const replies = page - .getByTestId("message-thread-replies") - .getByTestId("message-row"); - const before = await replies.count(); - await page.locator(`[data-thread-head-id="${replyId}"]`).click(); - await expect.poll(() => replies.count()).toBeGreaterThan(before); -} - test.describe("thread unread indicator", () => { test("01-thread-unread-badge", async ({ page }) => { await installMockBridge(page); @@ -277,9 +259,8 @@ test.describe("thread unread indicator", () => { await waitForMockLiveSubscription(page, "general"); // Build a genuinely nested branch by chaining parentEventId: each reply's - // id becomes the next reply's parent, so threadPanel increments depth per - // level and renders progressive indentation. The first three levels are - // dated in the past — they are the "already read" structure. + // id becomes the next reply's parent. The panel now presents that whole + // thread as a flat list, while unread counting still walks the subtree. const past = Math.floor(Date.now() / 1000) - 60; const r1 = await emitMockMessage( page, @@ -313,15 +294,15 @@ test.describe("thread unread indicator", () => { createdAt: past + 3, }); - // Open the thread on the welcome root, expand the read structure - // (r1 → r2; r3 is a leaf until r4/r5 arrive), then close. This sets the - // read frontier over everything that currently exists. + // Open the thread on the welcome root, then close. The flat panel marks the + // currently visible descendants read. const summary = page.getByTestId("message-thread-summary").first(); await expect(summary).toBeVisible(); await summary.click(); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); - await expandReply(page, r1.id); - await expandReply(page, r2.id); + await expect( + page.getByTestId("message-thread-replies").getByTestId("message-row"), + ).toHaveCount(4); await page.getByTestId("message-thread-close").click(); await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); @@ -342,63 +323,30 @@ test.describe("thread unread indicator", () => { createdAt: base + 1, }); - // Switch back, open the thread, and expand every level down to the - // unread tail. Each expandReply asserts a row appeared, so green here - // means the nesting genuinely rendered — not just that a divider exists. + // Switch back and open the thread. All descendants, including the unread + // tail, should be visible without expanding nested branches. await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByTestId("message-thread-summary").first().click(); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); - await expandReply(page, r1.id); - await expandReply(page, r2.id); - await expandReply(page, r3.id); - await expandReply(page, r4.id); // Fully expanded: r1, r2, sibling, r3, r4, r5 — six rendered replies. const replies = page .getByTestId("message-thread-replies") .getByTestId("message-row"); await expect(replies).toHaveCount(6); + await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(0); + await expect(page.getByTestId("thread-collapse-guide")).toHaveCount(0); + await expect( + page + .getByTestId("message-thread-replies") + .getByTestId("message-thread-summary"), + ).toHaveCount(0); const divider = page.getByTestId("message-unread-divider"); await expect(divider).toBeVisible(); await divider.scrollIntoViewIfNeeded(); await page.waitForTimeout(300); - - const panel = page.getByTestId("message-thread-panel"); - await page.getByTestId("message-thread-head").scrollIntoViewIfNeeded(); - await expect( - panel.locator( - `[data-testid="thread-collapse-rail"][data-thread-head-id="mock-general-welcome"]`, - ), - ).toHaveCount(0); - await expect( - panel.locator( - `[data-testid="thread-collapse-guide"][data-thread-head-id="mock-general-welcome"]`, - ), - ).toHaveCount(0); - - await page - .locator( - `[data-testid="thread-collapse-guide"][data-thread-head-id="${r1.id}"]`, - ) - .first() - .click(); - await expect(replies).toHaveCount(1); - await expect( - page - .getByTestId("message-thread-replies") - .locator( - `[data-testid="message-thread-summary"][data-thread-head-id="${r1.id}"]`, - ), - ).toBeVisible(); - await expect( - page - .getByTestId("message-thread-replies") - .locator( - `[data-testid="thread-collapse-rail"][data-thread-head-id="${r1.id}"]`, - ), - ).toHaveCount(0); }); test("05-thread-in-panel-subtree-badge", async ({ page }) => { @@ -410,9 +358,7 @@ test.describe("thread unread indicator", () => { await waitForMockLiveSubscription(page, "general"); // A branch p (with a child c) plus a leaf sibling of p, all dated in the - // past so they form the "already read" structure. p keeps a child, so its - // in-panel row renders as a collapsible summary that can carry a subtree - // badge; the leaf sibling proves the panel shows other rows too. + // past so they form the "already read" structure. const past = Math.floor(Date.now() / 1000) - 60; const p = await emitMockMessage(page, "general", "Branch parent", { parentEventId: "mock-general-welcome", @@ -431,8 +377,7 @@ test.describe("thread unread indicator", () => { }); // Open the thread to snapshot the read frontier over the existing - // structure, then close. p stays collapsed — its summary row must remain a - // collapsed branch for the subtree badge to render. + // structure, then close. const summary = page.getByTestId("message-thread-summary").first(); await expect(summary).toBeVisible(); await summary.click(); @@ -440,8 +385,7 @@ test.describe("thread unread indicator", () => { await page.getByTestId("message-thread-close").click(); await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); - // Switch away, then emit two unread replies deep under p (children of c) — - // p's subtree gains unread descendants while p itself stays collapsed. + // Switch away, then emit two unread replies deep under p (children of c). await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); @@ -462,44 +406,39 @@ test.describe("thread unread indicator", () => { createdAt: base + 1, }); - // Switch back and open the panel WITHOUT expanding p. The collapsed p row - // must show its subtree unread count (the two unread descendants). + // Switch back. The root summary still counts unread descendants even + // though the panel will render them flat. await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); + const rootBadge = page + .getByTestId("message-thread-summary") + .first() + .getByTestId("thread-unread-badge"); + await expect(rootBadge).toContainText("2"); await page.getByTestId("message-thread-summary").first().click(); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); - // p renders as a collapsed summary row (it has a child); the sibling is a - // leaf and renders as a plain row, not a summary. Gate on p's summary row - // first — green here means the branch genuinely rendered, so the badge - // assertion below is read off a real collapsed row, not an empty panel. - const inPanelSummaries = page - .getByTestId("message-thread-replies") - .getByTestId("message-thread-summary"); - await expect(inPanelSummaries).toHaveCount(1); - - // Scope to message-thread-replies: this is the in-panel per-branch badge, - // NOT the depth-0 channel-timeline badge that lives outside the container. - // Against pre-2.5 code the in-panel badge was hard-0, so this fails there. - const inPanelBadge = page + const replies = page .getByTestId("message-thread-replies") - .getByTestId("thread-unread-badge"); - await expect(inPanelBadge).toBeVisible(); - await expect(inPanelBadge).toContainText("2"); - - // v3 contract: expanding a branch marks only its REVEALED direct children - // read, never the whole subtree. The unread replies sit two levels under p - // (p -> c -> c2 -> c2-child), so a single expand of p only reveals c — the - // deeper unread stays collapsed and the badge survives. The badge clears - // only as each level is individually revealed: expand p (reveals c, badge - // still counts c2 + c2-child), expand c (reveals c2, read), expand c2 - // (reveals c2-child, read) -> badge clears to 0. - await expandReply(page, p.id); - await expect(inPanelBadge).toBeVisible(); - - await expandReply(page, c.id); - await expandReply(page, c2.id); - await expect(inPanelBadge).toHaveCount(0); + .getByTestId("message-row"); + await expect(replies).toHaveCount(5); + await expect( + page.getByText("Unread under the branch", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Another unread under the branch"), + ).toBeVisible(); + await expect( + page + .getByTestId("message-thread-replies") + .getByTestId("message-thread-summary"), + ).toHaveCount(0); + await expect( + page + .getByTestId("message-thread-replies") + .getByTestId("thread-unread-badge"), + ).toHaveCount(0); + await expect(page.getByTestId("message-unread-divider")).toBeVisible(); }); test("06-in-panel-badge-bumps-on-live-reply", async ({ page }) => { @@ -510,8 +449,7 @@ test.describe("thread unread indicator", () => { await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); - // Collapsed branch p with one read child, plus an unread descendant so the - // in-panel subtree badge starts at a known count. + // Branch p with one read child, plus an unread descendant. const past = Math.floor(Date.now() / 1000) - 60; const p = await emitMockMessage(page, "general", "Branch parent", { parentEventId: "mock-general-welcome", @@ -541,28 +479,32 @@ test.describe("thread unread indicator", () => { createdAt: base, }); - // Reopen WITHOUT expanding p: badge shows the single unread descendant. + // Reopen. The unread descendant is visible directly in the flat panel. await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByTestId("message-thread-summary").first().click(); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); - const inPanelBadge = page - .getByTestId("message-thread-replies") - .getByTestId("thread-unread-badge"); - await expect(inPanelBadge).toBeVisible(); - await expect(inPanelBadge).toContainText("1"); + await expect(page.getByText("First unread under branch")).toBeVisible(); + await expect( + page + .getByTestId("message-thread-replies") + .getByTestId("thread-unread-badge"), + ).toHaveCount(0); - // A live reply from another author lands under the open, collapsed branch. - // The live root marker did NOT advance (panel open ≠ branch expanded), so - // the badge must bump to 2 on the same tick — readStateVersion-driven - // recompute is what makes this fire live rather than on a later re-render. + // A live reply from another author lands under the open thread and appears + // as another flat reply instead of bumping an in-panel branch badge. await emitMockMessage(page, "general", "Second unread under branch", { parentEventId: c.id, pubkey: TEST_IDENTITIES.bob.pubkey, createdAt: base + 1, }); - await expect(inPanelBadge).toContainText("2"); + await expect(page.getByText("Second unread under branch")).toBeVisible(); + await expect( + page + .getByTestId("message-thread-replies") + .getByTestId("thread-unread-badge"), + ).toHaveCount(0); }); test("07-expand-clears-own-branch-badge-sibling-survives", async ({ @@ -575,7 +517,7 @@ test.describe("thread unread indicator", () => { await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); - // Two collapsed sibling branches, each with one read child. branchOld will + // Two sibling branches, each with one read child. branchOld will // gain a chronologically EARLIER unread reply; branchNew a LATER one. const past = Math.floor(Date.now() / 1000) - 120; const branchOld = await emitMockMessage(page, "general", "Older branch", { @@ -611,11 +553,7 @@ test.describe("thread unread indicator", () => { // Each branch gains its own unread reply, nested one level under the // branch's child (branchNew -> newChild -> unread; branchOld -> oldChild -> - // unread). Under the v3 per-message contract, expanding a branch marks only - // its REVEALED direct children read — so revealing newChild does NOT reach - // the unread reply beneath it. Clearing a branch's badge requires expanding - // down to the level the unread actually sits at; the sibling branch is - // never touched, so its badge survives independently. + // unread). const base = unreadTimestamp(); await emitMockMessage(page, "general", "Unread in older branch", { parentEventId: oldChild.id, @@ -630,29 +568,26 @@ test.describe("thread unread indicator", () => { await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); + const rootBadge = page + .getByTestId("message-thread-summary") + .first() + .getByTestId("thread-unread-badge"); + await expect(rootBadge).toContainText("2"); await page.getByTestId("message-thread-summary").first().click(); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); - // Both collapsed branches carry an unread badge before any expand. - const inPanelBadges = page + const replies = page .getByTestId("message-thread-replies") - .getByTestId("thread-unread-badge"); - await expect(inPanelBadges).toHaveCount(2); - - // Expand the LATER branch down to where its unread sits: revealing - // branchNew shows newChild (still collapsed over the unread reply, so the - // badge survives), then revealing newChild marks the unread reply read and - // clears branchNew's badge. The older sibling is never expanded, so its - // badge survives — per-message markers isolate each branch. - await expandReply(page, branchNew.id); - await expect(inPanelBadges).toHaveCount(2); - await expandReply(page, newChild.id); - await expect(inPanelBadges).toHaveCount(1); - - // Expanding the older branch to its unread depth clears the last badge. - await expandReply(page, branchOld.id); - await expandReply(page, oldChild.id); - await expect(inPanelBadges).toHaveCount(0); + .getByTestId("message-row"); + await expect(replies).toHaveCount(6); + await expect(page.getByText("Unread in older branch")).toBeVisible(); + await expect(page.getByText("Unread in newer branch")).toBeVisible(); + await expect( + page + .getByTestId("message-thread-replies") + .getByTestId("thread-unread-badge"), + ).toHaveCount(0); + await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(0); }); // Regression guard for the Option-1 channel-marker fix: viewing a channel @@ -858,15 +793,9 @@ test.describe("thread unread indicator", () => { // Regression guard for the mention-gate + subtree-count fixes. The viewer is // a pure MENTION RECIPIENT of a nested reply in a thread they never authored, // participated in, or followed: root `mock-general-alice` (Alice-authored) -> - // reply A (Alice) -> reply B (Alice, @-mentions self). This fails pre-fix on - // TWO independent defects: - // 1. The badge gate `isNotifiedForThread` had no mention term, so a - // recipient who never participated/authored/followed gated false and the - // badge never appeared at all. - // 2. `computeThreadBadgeCounts` counted only the root's DIRECT children, so - // the nested mention reply B (under A) was never tallied toward the root. - // After the gate fix the badge appears but undercounts (1, missing B); only - // after the subtree-count fix does it reach 2. Asserting `2` gates both. + // reply A (Alice) -> reply B (Alice, @-mentions self). The root badge must + // count the whole unread subtree, while opening the flat thread panel should + // reveal and mark both replies read without branch expansion. test("14-mention-only-nested-thread-badge", async ({ page }) => { await installMockBridge(page); await page.goto("/"); @@ -911,17 +840,13 @@ test.describe("thread unread indicator", () => { await expect(badge).toBeVisible(); await expect(badge).toContainText("2"); - // v3 contract: opening a thread marks only its REVEALED direct children - // read, never the whole subtree. Opening Alice's thread reveals direct - // child A (read), but nested mention B stays collapsed under A — so the - // root badge drops to 1, not 0. Expanding A reveals B, marks it read, and - // clears the badge. The badge predicate reads the live per-message marker, - // not a subtree-max open ceiling. await aliceSummary.click(); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); - await expect(badge).toContainText("1"); - - await expandReply(page, replyA?.id ?? ""); + await expect(page.getByText("Reply A (depth 1)")).toBeVisible(); + await expect( + page.getByText("Reply B mentioning you (depth 2)"), + ).toBeVisible(); + await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(0); await expect(badge).toHaveCount(0); await page.getByTestId("message-thread-close").click(); From 0a1acaae862aeb1482211b12a08f8538debb6d65 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 27 Jun 2026 11:39:03 +0100 Subject: [PATCH 4/8] Scope task storage and scroll restoration --- desktop/src/app/AppShell.tsx | 207 +++----------- .../agents/agentConversations.test.mjs | 10 +- .../src/features/agents/agentConversations.ts | 37 ++- .../agents/useAgentConversationShellState.ts | 261 ++++++++++++++++++ .../features/messages/ui/MessageTimeline.tsx | 7 +- 5 files changed, 335 insertions(+), 187 deletions(-) create mode 100644 desktop/src/features/agents/useAgentConversationShellState.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index ddcb3a8ab..7178eaa6f 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -18,18 +18,8 @@ import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotific import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems"; import { useTauriWindowDrag } from "@/app/useTauriWindowDrag"; import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; -import { - buildAgentConversation, - type AgentConversation, - type AgentConversationTitleStatus, - type OpenAgentConversationInput, - publishAgentConversationMarker, - readHiddenAgentConversationIds, - readPersistedAgentConversations, - writeHiddenAgentConversationIds, - writePersistedAgentConversations, -} from "@/features/agents/agentConversations"; import { AgentConversationScreen } from "@/features/agents/ui/AgentConversationScreen"; +import { useAgentConversationShellState } from "@/features/agents/useAgentConversationShellState"; import { channelsQueryKey, useChannelsQuery, @@ -113,15 +103,6 @@ export function AppShell() { const [isNewDmOpen, setIsNewDmOpen] = React.useState(false); const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false); const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false); - const [agentConversations, setAgentConversations] = React.useState< - AgentConversation[] - >([]); - const [hiddenAgentConversationIds, setHiddenAgentConversationIds] = - React.useState>(() => new Set()); - const [agentConversationStoragePubkey, setAgentConversationStoragePubkey] = - React.useState(null); - const [selectedAgentConversationId, setSelectedAgentConversationId] = - React.useState(null); const mainInsetRef = React.useRef(null); const location = useLocation(); const queryClient = useQueryClient(); @@ -156,27 +137,6 @@ export function AppShell() { const identityQuery = useIdentityQuery(); useAgentsDataRefresh(); const currentPubkey = identityQuery.data?.pubkey; - React.useEffect(() => { - if (!currentPubkey) { - setAgentConversations([]); - setHiddenAgentConversationIds(new Set()); - setAgentConversationStoragePubkey(null); - return; - } - - setAgentConversations(readPersistedAgentConversations(currentPubkey)); - setHiddenAgentConversationIds( - readHiddenAgentConversationIds(currentPubkey), - ); - setAgentConversationStoragePubkey(currentPubkey); - }, [currentPubkey]); - React.useEffect(() => { - if (!currentPubkey || agentConversationStoragePubkey !== currentPubkey) { - return; - } - - writePersistedAgentConversations(currentPubkey, agentConversations); - }, [agentConversationStoragePubkey, agentConversations, currentPubkey]); const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes(currentPubkey); const { starredChannelIds, starChannel, unstarChannel } = @@ -237,24 +197,26 @@ export function AppShell() { ? (channels.find((channel) => channel.id === targetChannelId) ?? null) : null; }, [channels, managedChannelId, selectedChannelId]); - const selectedAgentConversation = - selectedView === "agents" && selectedAgentConversationId - ? (agentConversations.find( - (conversation) => conversation.id === selectedAgentConversationId, - ) ?? null) - : null; - const visibleAgentConversations = React.useMemo( - () => - agentConversations.filter( - (conversation) => !hiddenAgentConversationIds.has(conversation.id), - ), - [agentConversations, hiddenAgentConversationIds], - ); - const selectedAgentConversationChannel = selectedAgentConversation - ? (channels.find( - (channel) => channel.id === selectedAgentConversation.channelId, - ) ?? null) - : null; + const { + agentConversations, + backToAgentConversationThread: handleBackToAgentConversationThread, + clearSelectedAgentConversation, + hideAgentConversation: handleHideAgentConversation, + openAgentConversation: handleOpenAgentConversation, + selectAgentConversation: handleSelectAgentConversation, + selectedAgentConversation, + selectedAgentConversationChannel, + selectedAgentConversationId, + updateAgentConversationTitle: handleUpdateAgentConversationTitle, + visibleAgentConversations, + } = useAgentConversationShellState({ + channels, + currentPubkey, + goAgents, + goChannel, + selectedView, + workspaceScope: workspacesHook.activeWorkspace?.relayUrl ?? null, + }); const { handleChannelNotification, @@ -480,118 +442,17 @@ export function AppShell() { const handleOpenSearchResult = React.useCallback( (hit: SearchHit) => { - setSelectedAgentConversationId(null); + clearSelectedAgentConversation(); void openSearchHit(hit); }, - [openSearchHit], - ); - const handleOpenAgentConversation = React.useCallback( - ( - input: OpenAgentConversationInput, - options?: { publishMarker?: boolean }, - ) => { - const conversation = buildAgentConversation(input); - if (options?.publishMarker !== false) { - void publishAgentConversationMarker(input).catch((error) => { - console.warn("[agentConversations] marker publish failed:", error); - }); - } - if (currentPubkey) { - setHiddenAgentConversationIds((current) => { - if (!current.has(conversation.id)) { - return current; - } - - const next = new Set(current); - next.delete(conversation.id); - writeHiddenAgentConversationIds(currentPubkey, next); - return next; - }); - } - setAgentConversations((current) => { - const existingIndex = current.findIndex( - (item) => item.id === conversation.id, - ); - if (existingIndex < 0) { - return [conversation, ...current]; - } - - const next = [...current]; - next.splice(existingIndex, 1); - return [conversation, ...next]; - }); - setSelectedAgentConversationId(conversation.id); - void goAgents(); - }, - [currentPubkey, goAgents], - ); - const handleUpdateAgentConversationTitle = React.useCallback( - ( - conversationId: string, - title: string, - titleStatus: AgentConversationTitleStatus, - ) => { - setAgentConversations((current) => - current.map((conversation) => - conversation.id === conversationId - ? { ...conversation, title, titleStatus } - : conversation, - ), - ); - }, - [], - ); - const handleHideAgentConversation = React.useCallback( - (conversationId: string) => { - const conversation = - agentConversations.find((item) => item.id === conversationId) ?? null; - if (!currentPubkey) { - return; - } - - setHiddenAgentConversationIds((current) => { - if (current.has(conversationId)) { - return current; - } - - const next = new Set(current); - next.add(conversationId); - writeHiddenAgentConversationIds(currentPubkey, next); - return next; - }); - - if (selectedAgentConversationId === conversationId) { - setSelectedAgentConversationId(null); - if (conversation) { - void goChannel(conversation.channelId); - } - } - }, - [agentConversations, currentPubkey, goChannel, selectedAgentConversationId], - ); - const handleSelectAgentConversation = React.useCallback( - (conversationId: string) => { - setSelectedAgentConversationId(conversationId); - void goAgents(); - }, - [goAgents], - ); - const handleBackToAgentConversationThread = React.useCallback( - (conversation: AgentConversation) => { - setSelectedAgentConversationId(null); - void goChannel(conversation.channelId, { - messageId: conversation.agentReply.id, - threadRootId: conversation.threadRootId, - }); - }, - [goChannel], + [clearSelectedAgentConversation, openSearchHit], ); const handleSelectChannel = React.useCallback( (channelId: string) => { - setSelectedAgentConversationId(null); + clearSelectedAgentConversation(); void goChannel(channelId); }, - [goChannel], + [clearSelectedAgentConversation, goChannel], ); // Prevent webview file:/// navigation on file drop outside the composer. @@ -886,7 +747,7 @@ export function AppShell() { createdChannel.id, name, ); - setSelectedAgentConversationId(null); + clearSelectedAgentConversation(); await goChannel(createdChannel.id); void applyAgents(templateId, createdChannel.id); }} @@ -911,7 +772,7 @@ export function AppShell() { createdForum.id, name, ); - setSelectedAgentConversationId(null); + clearSelectedAgentConversation(); await goChannel(createdForum.id); void applyAgents(templateId, createdForum.id); }} @@ -925,14 +786,14 @@ export function AppShell() { await openDmMutation.mutateAsync({ pubkeys, }); - setSelectedAgentConversationId(null); + clearSelectedAgentConversation(); await goChannel(directMessage.id); }} onSelectAgentConversation={ handleSelectAgentConversation } onSelectAgents={() => { - setSelectedAgentConversationId(null); + clearSelectedAgentConversation(); void goAgents(); }} onSelectChannel={handleSelectChannel} @@ -940,20 +801,20 @@ export function AppShell() { searchChannels={channels} searchFocusRequest={searchFocusRequest} onSelectHome={() => { - setSelectedAgentConversationId(null); + clearSelectedAgentConversation(); void goHome(); }} onSelectProjects={() => { - setSelectedAgentConversationId(null); + clearSelectedAgentConversation(); void goProjects(); }} onSelectPulse={() => { - setSelectedAgentConversationId(null); + clearSelectedAgentConversation(); void goPulse(); }} onSelectSettings={handleOpenSettings} onSelectWorkflows={() => { - setSelectedAgentConversationId(null); + clearSelectedAgentConversation(); void goWorkflows(); }} onSetPresenceStatus={(status) => @@ -1035,7 +896,7 @@ export function AppShell() { onDeleteActiveChannel={() => { setIsChannelManagementOpen(false); setManagedChannelId(null); - setSelectedAgentConversationId(null); + clearSelectedAgentConversation(); void goHome({ replace: true }); }} onSelectChannel={handleSelectChannel} diff --git a/desktop/src/features/agents/agentConversations.test.mjs b/desktop/src/features/agents/agentConversations.test.mjs index 8cfa2180b..696444633 100644 --- a/desktop/src/features/agents/agentConversations.test.mjs +++ b/desktop/src/features/agents/agentConversations.test.mjs @@ -176,6 +176,7 @@ test("continued conversation marker parses summary metadata", () => { test("continued conversations persist across app restarts", () => { withMockLocalStorage(() => { + const workspaceScope = "wss://relay.example.com"; const root = message({ body: "Can you look at the Buzz data model?", createdAt: 1, @@ -197,13 +198,18 @@ test("continued conversations persist across app restarts", () => { threadRootMessage: root, }); - writePersistedAgentConversations("human", [conversation]); - const persisted = readPersistedAgentConversations("human"); + writePersistedAgentConversations("human", workspaceScope, [conversation]); + const persisted = readPersistedAgentConversations("human", workspaceScope); + const otherWorkspace = readPersistedAgentConversations( + "human", + "wss://other.example.com", + ); assert.equal(persisted.length, 1); assert.equal(persisted[0].id, conversation.id); assert.equal(persisted[0].channelId, "channel"); assert.equal(persisted[0].agentReply.id, "agent-reply"); + assert.equal(otherWorkspace.length, 0); }); }); diff --git a/desktop/src/features/agents/agentConversations.ts b/desktop/src/features/agents/agentConversations.ts index 94fe37e2e..41224823a 100644 --- a/desktop/src/features/agents/agentConversations.ts +++ b/desktop/src/features/agents/agentConversations.ts @@ -85,12 +85,25 @@ export type AgentConversationRouteableParticipant = { pubkey: string; }; -export function hiddenAgentConversationsStorageKey(pubkey: string): string { - return `${HIDDEN_AGENT_CONVERSATIONS_STORAGE_PREFIX}:${pubkey}`; +function normalizeAgentConversationStorageScope( + workspaceScope: string | null | undefined, +): string { + const normalizedScope = workspaceScope?.trim().replace(/\/+$/, ""); + return normalizedScope || "unknown-workspace"; } -export function agentConversationsStorageKey(pubkey: string): string { - return `${AGENT_CONVERSATIONS_STORAGE_PREFIX}:${pubkey}`; +export function hiddenAgentConversationsStorageKey( + pubkey: string, + workspaceScope: string | null | undefined, +): string { + return `${HIDDEN_AGENT_CONVERSATIONS_STORAGE_PREFIX}:${normalizeAgentConversationStorageScope(workspaceScope)}:${pubkey}`; +} + +export function agentConversationsStorageKey( + pubkey: string, + workspaceScope: string | null | undefined, +): string { + return `${AGENT_CONVERSATIONS_STORAGE_PREFIX}:${normalizeAgentConversationStorageScope(workspaceScope)}:${pubkey}`; } export function getAutoRoutedAgentConversationPubkeys( @@ -133,10 +146,13 @@ export function buildAgentConversationMentionPubkeys({ return merged; } -export function readHiddenAgentConversationIds(pubkey: string): Set { +export function readHiddenAgentConversationIds( + pubkey: string, + workspaceScope: string | null | undefined, +): Set { try { const raw = window.localStorage.getItem( - hiddenAgentConversationsStorageKey(pubkey), + hiddenAgentConversationsStorageKey(pubkey, workspaceScope), ); if (!raw) { return new Set(); @@ -157,11 +173,12 @@ export function readHiddenAgentConversationIds(pubkey: string): Set { export function writeHiddenAgentConversationIds( pubkey: string, + workspaceScope: string | null | undefined, ids: ReadonlySet, ): void { try { window.localStorage.setItem( - hiddenAgentConversationsStorageKey(pubkey), + hiddenAgentConversationsStorageKey(pubkey, workspaceScope), JSON.stringify([...ids]), ); } catch { @@ -291,10 +308,11 @@ function parseStoredAgentConversation( export function readPersistedAgentConversations( pubkey: string, + workspaceScope: string | null | undefined, ): AgentConversation[] { try { const raw = window.localStorage.getItem( - agentConversationsStorageKey(pubkey), + agentConversationsStorageKey(pubkey, workspaceScope), ); if (!raw) { return []; @@ -323,6 +341,7 @@ export function readPersistedAgentConversations( export function writePersistedAgentConversations( pubkey: string, + workspaceScope: string | null | undefined, conversations: readonly AgentConversation[], ): void { try { @@ -335,7 +354,7 @@ export function writePersistedAgentConversations( .sort((left, right) => right.createdAt - left.createdAt) .slice(0, MAX_PERSISTED_AGENT_CONVERSATIONS); window.localStorage.setItem( - agentConversationsStorageKey(pubkey), + agentConversationsStorageKey(pubkey, workspaceScope), JSON.stringify(persisted), ); } catch { diff --git a/desktop/src/features/agents/useAgentConversationShellState.ts b/desktop/src/features/agents/useAgentConversationShellState.ts new file mode 100644 index 000000000..fdfeca685 --- /dev/null +++ b/desktop/src/features/agents/useAgentConversationShellState.ts @@ -0,0 +1,261 @@ +import * as React from "react"; + +import type { Channel } from "@/shared/api/types"; +import { + buildAgentConversation, + type AgentConversation, + type AgentConversationTitleStatus, + type OpenAgentConversationInput, + publishAgentConversationMarker, + readHiddenAgentConversationIds, + readPersistedAgentConversations, + writeHiddenAgentConversationIds, + writePersistedAgentConversations, +} from "./agentConversations"; + +type GoAgents = () => Promise; +type GoChannel = ( + channelId: string, + options?: { + messageId?: string; + replace?: boolean; + taskReplyId?: string; + threadRootId?: string | null; + }, +) => Promise; + +type AgentConversationShellStateInput = { + channels: readonly Channel[]; + currentPubkey?: string; + enabled?: boolean; + goAgents: GoAgents; + goChannel: GoChannel; + selectedView: string; + workspaceScope?: string | null; +}; + +export function useAgentConversationShellState({ + channels, + currentPubkey, + enabled = true, + goAgents, + goChannel, + selectedView, + workspaceScope, +}: AgentConversationShellStateInput) { + const [agentConversations, setAgentConversations] = React.useState< + AgentConversation[] + >([]); + const [hiddenAgentConversationIds, setHiddenAgentConversationIds] = + React.useState>(() => new Set()); + const [agentConversationStorageKey, setAgentConversationStorageKey] = + React.useState(null); + const [selectedAgentConversationId, setSelectedAgentConversationId] = + React.useState(null); + const activeStorageKey = + currentPubkey && workspaceScope + ? `${workspaceScope}:${currentPubkey}` + : null; + + React.useEffect(() => { + if (!currentPubkey || !workspaceScope) { + setAgentConversations([]); + setHiddenAgentConversationIds(new Set()); + setAgentConversationStorageKey(null); + return; + } + + setAgentConversations( + readPersistedAgentConversations(currentPubkey, workspaceScope), + ); + setHiddenAgentConversationIds( + readHiddenAgentConversationIds(currentPubkey, workspaceScope), + ); + setAgentConversationStorageKey(activeStorageKey); + }, [activeStorageKey, currentPubkey, workspaceScope]); + + React.useEffect(() => { + if ( + !currentPubkey || + !workspaceScope || + agentConversationStorageKey !== activeStorageKey + ) { + return; + } + + writePersistedAgentConversations( + currentPubkey, + workspaceScope, + agentConversations, + ); + }, [ + activeStorageKey, + agentConversationStorageKey, + agentConversations, + currentPubkey, + workspaceScope, + ]); + + React.useEffect(() => { + if (!enabled) { + setSelectedAgentConversationId(null); + } + }, [enabled]); + + const selectedAgentConversation = + enabled && selectedView === "agents" && selectedAgentConversationId + ? (agentConversations.find( + (conversation) => conversation.id === selectedAgentConversationId, + ) ?? null) + : null; + + const visibleAgentConversations = React.useMemo( + () => + enabled + ? agentConversations.filter( + (conversation) => !hiddenAgentConversationIds.has(conversation.id), + ) + : [], + [agentConversations, enabled, hiddenAgentConversationIds], + ); + + const selectedAgentConversationChannel = selectedAgentConversation + ? (channels.find( + (channel) => channel.id === selectedAgentConversation.channelId, + ) ?? null) + : null; + + const clearSelectedAgentConversation = React.useCallback(() => { + setSelectedAgentConversationId(null); + }, []); + + const openAgentConversation = React.useCallback( + ( + input: OpenAgentConversationInput, + options?: { publishMarker?: boolean }, + ) => { + if (!enabled) { + return; + } + + const conversation = buildAgentConversation(input); + if (options?.publishMarker !== false) { + void publishAgentConversationMarker(input).catch((error) => { + console.warn("[agentConversations] marker publish failed:", error); + }); + } + if (currentPubkey && workspaceScope) { + setHiddenAgentConversationIds((current) => { + if (!current.has(conversation.id)) { + return current; + } + + const next = new Set(current); + next.delete(conversation.id); + writeHiddenAgentConversationIds(currentPubkey, workspaceScope, next); + return next; + }); + } + setAgentConversations((current) => { + const existingIndex = current.findIndex( + (item) => item.id === conversation.id, + ); + if (existingIndex < 0) { + return [conversation, ...current]; + } + + const next = [...current]; + next.splice(existingIndex, 1); + return [conversation, ...next]; + }); + setSelectedAgentConversationId(conversation.id); + void goAgents(); + }, + [currentPubkey, enabled, goAgents, workspaceScope], + ); + + const updateAgentConversationTitle = React.useCallback( + ( + conversationId: string, + title: string, + titleStatus: AgentConversationTitleStatus, + ) => { + setAgentConversations((current) => + current.map((conversation) => + conversation.id === conversationId + ? { ...conversation, title, titleStatus } + : conversation, + ), + ); + }, + [], + ); + + const hideAgentConversation = React.useCallback( + (conversationId: string) => { + const conversation = + agentConversations.find((item) => item.id === conversationId) ?? null; + if (!currentPubkey || !workspaceScope) { + return; + } + + setHiddenAgentConversationIds((current) => { + if (current.has(conversationId)) { + return current; + } + + const next = new Set(current); + next.add(conversationId); + writeHiddenAgentConversationIds(currentPubkey, workspaceScope, next); + return next; + }); + + if (selectedAgentConversationId === conversationId) { + setSelectedAgentConversationId(null); + if (conversation) { + void goChannel(conversation.channelId); + } + } + }, + [ + agentConversations, + currentPubkey, + goChannel, + selectedAgentConversationId, + workspaceScope, + ], + ); + + const selectAgentConversation = React.useCallback( + (conversationId: string) => { + setSelectedAgentConversationId(conversationId); + void goAgents(); + }, + [goAgents], + ); + + const backToAgentConversationThread = React.useCallback( + (conversation: AgentConversation) => { + setSelectedAgentConversationId(null); + void goChannel(conversation.channelId, { + messageId: conversation.agentReply.id, + threadRootId: conversation.threadRootId, + }); + }, + [goChannel], + ); + + return { + agentConversations: enabled ? agentConversations : [], + backToAgentConversationThread, + clearSelectedAgentConversation, + hideAgentConversation, + openAgentConversation, + selectAgentConversation, + selectedAgentConversation, + selectedAgentConversationChannel, + selectedAgentConversationId: enabled ? selectedAgentConversationId : null, + updateAgentConversationTitle, + visibleAgentConversations, + }; +} diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 12fbb4d1e..72787cf6e 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -300,15 +300,16 @@ const MessageTimelineBase = React.forwardRef< liveSnapshot, }); const isRenderPending = deferredSnapshot !== liveSnapshot; + const scrollRouteKey = `${channelId ?? "none"}:${layoutShiftKey ?? "none"}`; const scrollRestorationId = targetMessageId - ? `message-timeline:${channelId ?? "none"}:target:${targetMessageId}` - : `message-timeline:${channelId ?? "none"}`; + ? `message-timeline:${scrollRouteKey}:target:${targetMessageId}` + : `message-timeline:${scrollRouteKey}`; // Keep the scroll node's DOM lifetime scoped to a channel. TanStack Router's // scroll-restoration listener runs outside React and may write a saved // scrollTop into the current scroll element during navigation; reusing the // same node across channel routes can leave the newly-loaded message list // painted at a stale offset until the user's next scroll event forces layout. - const scrollContainerDomKey = `${channelId ?? "none"}:${layoutShiftKey ?? "none"}`; + const scrollContainerDomKey = scrollRouteKey; const timelineBodySurface = selectTimelineBodySurface({ deferredCount: deferredMessages.length, From ae13be9df60eb7b712a228b9212f15c1c020ea34 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 27 Jun 2026 11:56:22 +0100 Subject: [PATCH 5/8] Preserve thread reply targets --- desktop/src/features/messages/ui/MessageTimeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 72787cf6e..6713c034d 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -554,7 +554,7 @@ const MessageTimelineBase = React.forwardRef<
Date: Sun, 28 Jun 2026 08:00:58 +0100 Subject: [PATCH 6/8] Guard task starts in read-only channels --- .../channels/ui/ChannelPane.helpers.test.mjs | 39 +++++++++++++++++++ .../channels/ui/ChannelPane.helpers.ts | 18 +++++++++ .../src/features/channels/ui/ChannelPane.tsx | 10 ++++- 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs index 13dfc2bc9..4401ca709 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + canOpenAgentConversationInChannel, getDmAutoRouteAgentPubkeys, getThreadAutoRouteAgentPubkeys, mergeAutoRouteMentionPubkeys, @@ -153,3 +154,41 @@ test("auto-routed mentions merge with explicit mentions without duplicates", () ["AGENT-ONE", "agent-two"], ); }); + +test("new agent conversations require a writable channel", () => { + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel(), + }), + true, + ); + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ archivedAt: "2026-06-27T00:00:00.000Z" }), + }), + false, + ); + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ isMember: false }), + }), + false, + ); +}); + +test("existing agent conversation markers can open in read-only channels", () => { + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ archivedAt: "2026-06-27T00:00:00.000Z" }), + publishMarker: false, + }), + true, + ); + assert.equal( + canOpenAgentConversationInChannel({ + channel: channel({ isMember: false }), + publishMarker: false, + }), + true, + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index 929b39117..9525a846d 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -45,6 +45,24 @@ export function isWelcomeSetupSystemMessage(message: TimelineMessage) { } } +export function canOpenAgentConversationInChannel({ + channel, + publishMarker, +}: { + channel: Channel | null; + publishMarker?: boolean; +}) { + if (!channel) { + return false; + } + + if (publishMarker === false) { + return true; + } + + return channel.archivedAt === null && channel.isMember; +} + export function mentionsKnownAgent( mentionPubkeys: string[], knownAgentPubkeys: ReadonlySet, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index a908e6ac3..4d2831e97 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -47,6 +47,7 @@ import { type WelcomeComposerBannerState, } from "@/features/channels/ui/WelcomeComposerBanner"; import { + canOpenAgentConversationInChannel, getChannelIntroDescription, getChannelIntroKind, isWelcomeSetupSystemMessage, @@ -439,7 +440,14 @@ export const ChannelPane = React.memo(function ChannelPane({ ); const handleOpenAgentConversation = React.useCallback( (message: TimelineMessage, options?: { publishMarker?: boolean }) => { - if (!activeChannel || !message.pubkey) { + if ( + !activeChannel || + !message.pubkey || + !canOpenAgentConversationInChannel({ + channel: activeChannel, + publishMarker: options?.publishMarker, + }) + ) { return; } From a7b625f1f54cfa12c4f93a7ac894c092a35830b7 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 28 Jun 2026 08:57:51 +0100 Subject: [PATCH 7/8] Fix task scoping and agent start reconciliation --- crates/buzz-acp/src/queue.rs | 5 +- .../src/commands/agent_profile_reconcile.rs | 32 +++++++ desktop/src-tauri/src/commands/agents.rs | 8 +- desktop/src-tauri/src/commands/mod.rs | 1 + .../agents/agentConversations.test.mjs | 73 +++++++++++++++ .../ui/AgentConversationScreen.helpers.ts | 91 ++++++++++++++++++- .../agents/ui/AgentConversationScreen.tsx | 16 +++- 7 files changed, 208 insertions(+), 18 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_profile_reconcile.rs diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index f7df88cc4..d1999eef1 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1176,10 +1176,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec Result<(), String> { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(app)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + data.auth_tag = record.auth_tag.clone(); + Ok(()) + })(); + + if let Err(error) = result { + eprintln!( + "buzz-desktop: profile reconciliation using pre-start auth tag for agent {pubkey}: {error}" + ); + } +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 786d1dcdc..6dbd8c198 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -951,8 +951,7 @@ pub async fn start_managed_agent( } // Collect backend info under lock; async preflight/spawn happens below. - // Also snapshot profile reconciliation data for the background task. - let (target, reconcile_data) = { + let (target, mut reconcile_data) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -1049,9 +1048,10 @@ pub async fn start_managed_agent( // ── Profile reconciliation (fire-and-forget) ──────────────────────────── // On successful start, spawn a background task to ensure the agent's kind:0 // profile is published on the relay. This self-heals cases where the initial - // profile sync at creation time failed silently. For legacy records (pre-PR-921) - // with no persisted avatar, this also backfills the avatar from the relay. + // profile sync at creation time failed silently. if result.is_ok() { + use super::agent_profile_reconcile as reconcile; + reconcile::refresh_auth_tag(&app, &state, &pubkey, &mut reconcile_data); let reconcile_pubkey = pubkey.clone(); let reconcile_app = app.clone(); tauri::async_runtime::spawn(async move { diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index e8a2756eb..01f7d22c3 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ mod agent_discovery; mod agent_models; +mod agent_profile_reconcile; mod agent_settings; mod agents; mod canvas; diff --git a/desktop/src/features/agents/agentConversations.test.mjs b/desktop/src/features/agents/agentConversations.test.mjs index 696444633..f8103e4e0 100644 --- a/desktop/src/features/agents/agentConversations.test.mjs +++ b/desktop/src/features/agents/agentConversations.test.mjs @@ -13,6 +13,7 @@ import { readPersistedAgentConversations, writePersistedAgentConversations, } from "./agentConversations.ts"; +import { isConversationMessage } from "./ui/AgentConversationScreen.helpers.ts"; function message({ body, createdAt, id, pubkey = "human" }) { return { @@ -491,3 +492,75 @@ test("continued conversation markers keep later task anchors visible", () => { assert.deepEqual([...hiddenIds], ["hidden", "later"]); }); + +test("dedicated conversation view stops at the next task anchor", () => { + const root = message({ + body: "Can you look into the data model?", + createdAt: 1, + id: "root", + }); + const firstAnchor = message({ + body: "I'll look into it.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const firstTaskReply = message({ + body: "This belongs in the first task.", + createdAt: 3, + id: "first-task-reply", + }); + const secondAnchor = message({ + body: "Let's split this into another task.", + createdAt: 4, + id: "second-anchor", + pubkey: "agent", + }); + const secondTaskReply = message({ + body: "This belongs in the second task.", + createdAt: 5, + id: "second-task-reply", + }); + const messages = [ + root, + firstAnchor, + firstTaskReply, + secondAnchor, + secondTaskReply, + ]; + const conversation = buildAgentConversation({ + agentName: "Fizz", + agentPubkey: "agent", + agentReply: firstAnchor, + channel: { id: "channel", name: "general" }, + contextMessages: messages, + parentMessage: root, + threadRootMessage: root, + }); + const firstMarker = parseAgentConversationMarker( + markerEvent({ content: { startedAt: 2 }, createdAt: 2 }), + ); + const secondMarker = parseAgentConversationMarker({ + ...markerEvent({ + content: { agentReplyId: "second-anchor", startedAt: 4 }, + createdAt: 4, + id: "second-marker", + }), + tags: [ + ["h", "channel"], + ["e", "root", "", "root"], + ["e", "second-anchor", "", "agent-reply"], + ["p", "agent"], + ["title", "Second task"], + ], + }); + const markers = [firstMarker, secondMarker].filter(Boolean); + + const visibleIds = messages + .filter((entry) => + isConversationMessage(entry, conversation, markers, messages), + ) + .map((entry) => entry.id); + + assert.deepEqual(visibleIds, ["root", "agent-reply", "first-task-reply"]); +}); diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts b/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts index dba599794..9c933bc60 100644 --- a/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts +++ b/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts @@ -1,4 +1,7 @@ -import type { AgentConversation } from "@/features/agents/agentConversations"; +import type { + AgentConversation, + AgentConversationMarker, +} from "@/features/agents/agentConversations"; import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages"; import type { TimelineMessage, @@ -74,12 +77,90 @@ export function formatAgentParticipantNames( export function isConversationMessage( message: TimelineMessage, conversation: AgentConversation, + markers: readonly AgentConversationMarker[] = [], + messages: readonly TimelineMessage[] = [], ) { - return ( + if ( message.id === conversation.threadRootId || - message.id === conversation.agentReply.id || - message.rootId === conversation.threadRootId || - message.parentId === conversation.threadRootId + message.id === conversation.parentMessage?.id || + message.id === conversation.agentReply.id + ) { + return true; + } + + const messageThreadRootId = message.rootId ?? message.parentId ?? null; + if (messageThreadRootId !== conversation.threadRootId) { + return false; + } + + const orderedThreadMessages = + messages.length > 0 + ? messages.filter( + (candidate) => + candidate.id === conversation.threadRootId || + candidate.rootId === conversation.threadRootId || + candidate.parentId === conversation.threadRootId, + ) + : []; + const messageIndexById = new Map( + orderedThreadMessages.map((candidate, index) => [candidate.id, index]), + ); + const anchorIndex = messageIndexById.get(conversation.agentReply.id); + const messageIndex = messageIndexById.get(message.id); + + if (anchorIndex !== undefined && messageIndex !== undefined) { + if (messageIndex < anchorIndex) { + return false; + } + + let nextAnchorIndex = Number.POSITIVE_INFINITY; + for (const marker of markers) { + if ( + marker.channelId !== conversation.channelId || + marker.threadRootId !== conversation.threadRootId || + marker.agentReplyId === conversation.agentReply.id + ) { + continue; + } + + const markerAnchorIndex = messageIndexById.get(marker.agentReplyId); + if ( + markerAnchorIndex !== undefined && + markerAnchorIndex > anchorIndex && + markerAnchorIndex < nextAnchorIndex + ) { + nextAnchorIndex = markerAnchorIndex; + } + } + + return messageIndex < nextAnchorIndex; + } + + const currentMarker = + markers.find( + (marker) => + marker.channelId === conversation.channelId && + marker.threadRootId === conversation.threadRootId && + marker.agentReplyId === conversation.agentReply.id, + ) ?? null; + const selectedStartedAt = + currentMarker?.startedAt ?? conversation.agentReply.createdAt; + if (message.createdAt < selectedStartedAt) { + return false; + } + + const nextMarkerStartedAt = markers + .filter( + (marker) => + marker.channelId === conversation.channelId && + marker.threadRootId === conversation.threadRootId && + marker.agentReplyId !== conversation.agentReply.id && + marker.startedAt > selectedStartedAt, + ) + .sort((left, right) => left.startedAt - right.startedAt)[0]?.startedAt; + + return ( + nextMarkerStartedAt === undefined || message.createdAt < nextMarkerStartedAt ); } diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.tsx b/desktop/src/features/agents/ui/AgentConversationScreen.tsx index d9bffae54..1f77560a5 100644 --- a/desktop/src/features/agents/ui/AgentConversationScreen.tsx +++ b/desktop/src/features/agents/ui/AgentConversationScreen.tsx @@ -128,6 +128,10 @@ export function AgentConversationScreen({ const sendMessageMutation = useSendMessageMutation(channel, currentIdentity); const relayMessages = messagesQuery.data ?? []; + const agentConversationMarkers = React.useMemo( + () => buildAgentConversationMarkers(relayMessages), + [relayMessages], + ); const { getMessageReadAt, isThreadMuted, @@ -211,7 +215,12 @@ export function AgentConversationScreen({ profiles, ); const scoped = formatted.filter((message) => - isConversationMessage(message, conversation), + isConversationMessage( + message, + conversation, + agentConversationMarkers, + formatted, + ), ); const sourceMessages = scoped.length > 0 @@ -231,6 +240,7 @@ export function AgentConversationScreen({ ); }, [ channel, + agentConversationMarkers, conversation, currentIdentity?.pubkey, currentProfile?.avatarUrl, @@ -477,10 +487,6 @@ export function AgentConversationScreen({ React.useEffect(() => { lastPublishedThreadRecapRef.current = null; }, [conversation.id]); - const agentConversationMarkers = React.useMemo( - () => buildAgentConversationMarkers(relayMessages), - [relayMessages], - ); const currentConversationMarker = React.useMemo( () => agentConversationMarkers.find( From 75a20b271bc2cfd7b09f8962c3f89ca3d3add9fc Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 28 Jun 2026 09:54:53 +0100 Subject: [PATCH 8/8] Fix task view reply and read scoping --- .../agents/agentConversations.test.mjs | 93 +++++++++++++++++++ .../ui/AgentConversationScreen.helpers.ts | 49 +++++++++- .../agents/ui/AgentConversationScreen.tsx | 23 +++-- 3 files changed, 155 insertions(+), 10 deletions(-) diff --git a/desktop/src/features/agents/agentConversations.test.mjs b/desktop/src/features/agents/agentConversations.test.mjs index f8103e4e0..8443ce477 100644 --- a/desktop/src/features/agents/agentConversations.test.mjs +++ b/desktop/src/features/agents/agentConversations.test.mjs @@ -564,3 +564,96 @@ test("dedicated conversation view stops at the next task anchor", () => { assert.deepEqual(visibleIds, ["root", "agent-reply", "first-task-reply"]); }); + +test("dedicated conversation view keeps older task descendant replies", () => { + const root = message({ + body: "Can you look into the data model?", + createdAt: 1, + id: "root", + }); + const firstAnchor = message({ + body: "I'll look into it.", + createdAt: 2, + id: "agent-reply", + pubkey: "agent", + }); + const firstTaskReply = message({ + body: "This belongs in the first task.", + createdAt: 3, + id: "first-task-reply", + }); + const secondAnchor = message({ + body: "Let's split this into another task.", + createdAt: 4, + id: "second-anchor", + pubkey: "agent", + }); + const secondTaskReply = message({ + body: "This belongs in the second task.", + createdAt: 5, + id: "second-task-reply", + }); + const firstTaskFollowup = { + ...message({ + body: "Continuing the first task after task two started.", + createdAt: 6, + id: "first-task-followup", + }), + parentId: "first-task-reply", + rootId: "root", + }; + const plainRootReply = message({ + body: "A plain root reply after task two should not join task one.", + createdAt: 7, + id: "plain-root-reply", + }); + const messages = [ + root, + firstAnchor, + firstTaskReply, + secondAnchor, + secondTaskReply, + firstTaskFollowup, + plainRootReply, + ]; + const conversation = buildAgentConversation({ + agentName: "Fizz", + agentPubkey: "agent", + agentReply: firstAnchor, + channel: { id: "channel", name: "general" }, + contextMessages: messages, + parentMessage: root, + threadRootMessage: root, + }); + const firstMarker = parseAgentConversationMarker( + markerEvent({ content: { startedAt: 2 }, createdAt: 2 }), + ); + const secondMarker = parseAgentConversationMarker({ + ...markerEvent({ + content: { agentReplyId: "second-anchor", startedAt: 4 }, + createdAt: 4, + id: "second-marker", + }), + tags: [ + ["h", "channel"], + ["e", "root", "", "root"], + ["e", "second-anchor", "", "agent-reply"], + ["p", "agent"], + ["title", "Second task"], + ], + }); + const markers = [firstMarker, secondMarker].filter(Boolean); + + const visibleIds = messages + .filter((entry) => + isConversationMessage(entry, conversation, markers, messages), + ) + .map((entry) => entry.id); + + assert.deepEqual(visibleIds, [ + "root", + "agent-reply", + "first-task-reply", + "first-task-followup", + ]); +}); diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts b/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts index 9c933bc60..42d2eee85 100644 --- a/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts +++ b/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts @@ -93,6 +93,16 @@ export function isConversationMessage( return false; } + const markerAnchorIds = new Set( + markers + .filter( + (marker) => + marker.channelId === conversation.channelId && + marker.threadRootId === conversation.threadRootId && + marker.agentReplyId !== conversation.agentReply.id, + ) + .map((marker) => marker.agentReplyId), + ); const orderedThreadMessages = messages.length > 0 ? messages.filter( @@ -133,7 +143,44 @@ export function isConversationMessage( } } - return messageIndex < nextAnchorIndex; + if (messageIndex < nextAnchorIndex) { + return true; + } + + const selectedTaskMessageIds = new Set(); + for (const candidate of orderedThreadMessages) { + const candidateIndex = messageIndexById.get(candidate.id); + if ( + candidateIndex !== undefined && + candidateIndex >= anchorIndex && + candidateIndex < nextAnchorIndex + ) { + selectedTaskMessageIds.add(candidate.id); + } + } + selectedTaskMessageIds.delete(conversation.threadRootId); + + const messageById = new Map( + orderedThreadMessages.map((candidate) => [candidate.id, candidate]), + ); + let parentId = message.parentId; + const visited = new Set([message.id]); + while (parentId && !visited.has(parentId)) { + if (selectedTaskMessageIds.has(parentId)) { + return true; + } + if ( + parentId === conversation.threadRootId || + markerAnchorIds.has(parentId) + ) { + return false; + } + + visited.add(parentId); + parentId = messageById.get(parentId)?.parentId ?? null; + } + + return false; } const currentMarker = diff --git a/desktop/src/features/agents/ui/AgentConversationScreen.tsx b/desktop/src/features/agents/ui/AgentConversationScreen.tsx index 1f77560a5..9d4294a55 100644 --- a/desktop/src/features/agents/ui/AgentConversationScreen.tsx +++ b/desktop/src/features/agents/ui/AgentConversationScreen.tsx @@ -135,7 +135,6 @@ export function AgentConversationScreen({ const { getMessageReadAt, isThreadMuted, - markThreadRead, markMessageRead, updateAgentConversationTitle, } = useAppShell(); @@ -420,11 +419,6 @@ export function AgentConversationScreen({ }); }, [conversation, conversationSourceMessages, updateAgentConversationTitle]); React.useEffect(() => { - const latestMessage = timelineMessages[timelineMessages.length - 1] ?? null; - if (latestMessage) { - markThreadRead(conversation.threadRootId, latestMessage.createdAt); - } - if (isThreadMuted(conversation.threadRootId)) { return; } @@ -440,9 +434,19 @@ export function AgentConversationScreen({ getMessageReadAt, isThreadMuted, markMessageRead, - markThreadRead, timelineMessages, ]); + const replyParentEventId = React.useMemo(() => { + const latestTaskMessage = [...timelineMessages] + .reverse() + .find((message) => message.id !== conversation.threadRootId); + + return ( + latestTaskMessage?.id ?? + conversation.agentReply.id ?? + conversation.threadRootId + ); + }, [conversation.agentReply.id, conversation.threadRootId, timelineMessages]); const routeableAgentPubkeys = React.useMemo( () => agentParticipants @@ -539,10 +543,10 @@ export function AgentConversationScreen({ autoRouteAgentPubkeys: autoRoutedAgentPubkeys, mentionPubkeys, }), - parentEventId: conversation.threadRootId, + parentEventId: replyParentEventId, }); }, - [autoRoutedAgentPubkeys, conversation.threadRootId, sendMessageMutation], + [autoRoutedAgentPubkeys, replyParentEventId, sendMessageMutation], ); const isComposerDisabled = @@ -734,6 +738,7 @@ export function AgentConversationScreen({ emptyTitle="No conversation messages yet" hasComposerOverlay isLoading={messagesQuery.isLoading && timelineMessages.length === 0} + layoutShiftKey={conversation.id} messageListPlacement="top" messages={timelineMessages} profiles={profiles}