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 = (
+
+
+ void handleSendSummaryToThread()}
+ title="Send recap to thread"
+ type="button"
+ variant="outline"
+ >
+
+
+ {isPublishingThreadSummary
+ ? "Generating recap..."
+ : "Send recap to thread"}
+
+
+
+
+ Add a conversation recap to the original thread
+
+
+ );
+ const headerLeadingContent = onBackToThread ? (
+
+
+ onBackToThread(conversation)}
+ title="Back to source thread"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+ 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
+ )}
void handleCopyTitle()}
size="icon-xs"
title="Copy channel name"
@@ -153,6 +203,16 @@ export function ChatHeader({
) : 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}
+
+
+
+ onOpenAgentConversation?.(message, { publishMarker: false })
+ }
+ type="button"
+ variant="outline"
+ >
+ Open
+
+
+ {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 ? (
+
+
+ {
+ onContinueConversation?.(message);
+ }}
+ size="sm"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+ 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 ? (
+
) : 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
-
- {activeWord}
-
-
-
- {activeCharacters.map(({ character, key }) => (
-
- {character}
-
- ))}
-
-
-
+
);
}
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 ? (
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ onHideConversation(conversation.id);
+ }}
+ title="Close conversation"
+ type="button"
+ >
+
+
+ ) : 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 ? (
- {
- event.stopPropagation();
- onHideDm(channel.id);
- }}
- type="button"
- >
-
-
- ) : null}
+ {channel.channelType === "dm" &&
+ unreadChannelIds.has(channel.id) &&
+ !(isActiveChannel && selectedChannelId === channel.id) ? (
+
+ ) : null}
+ {channel.channelType === "dm" && onHideDm ? (
+ {
+ event.stopPropagation();
+ onHideDm(channel.id);
+ }}
+ type="button"
+ >
+
+
+ ) : 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} }
+
+ {value}
+
+
+
+ {activeCharacters.map(({ character, key }) => (
+
+ {character}
+
+ ))}
+
+
+
+ );
+}
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}