diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 21fa41cac..f652d89c8 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -1753,21 +1753,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,
@@ -1931,7 +1916,6 @@ 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();
// Clone for the non-cancelling steer fork, which
// needs the event to render the steer body. The
// clone is unconditional because we don't know
@@ -1950,18 +1934,7 @@ async fn tokio_main() -> Result<()> {
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();
- let eid = event_id_hex.clone();
- tokio::spawn(async move {
- pool::reaction_add(&rc, &eid, "π").await;
- });
- }
+ // ββ Multiple-event-handling mode gate βββββββββββββ
// Event is already queued. If mode requires it AND
// the channel has an in-flight task, fire cancel β
// OR take the non-cancelling (ACP steer) fork for Steer signals.
diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs
index d6b6f82f9..55989b957 100644
--- a/crates/buzz-acp/src/pool.rs
+++ b/crates/buzz-acp/src/pool.rs
@@ -1046,15 +1046,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(
@@ -1405,16 +1397,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
@@ -1730,7 +1713,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`
@@ -1847,7 +1829,14 @@ async fn fetch_conversation_context(
let last_event = batch.events.last()?;
let tags = crate::queue::parse_thread_tags(&last_event.event);
if let Some(root_id) = tags.root_event_id {
- return fetch_thread_context(batch.channel_id, &root_id, limit, &ctx.rest_client).await;
+ return fetch_thread_context(
+ batch.channel_id,
+ &root_id,
+ tags.agent_reply_event_id.as_deref(),
+ limit,
+ &ctx.rest_client,
+ )
+ .await;
}
// DM non-reply: fetch recent conversation history.
@@ -2012,16 +2001,14 @@ async fn fetch_prompt_profile_lookup(
async fn fetch_thread_context(
channel_id: Uuid,
root_event_id: &str,
+ agent_reply_event_id: Option<&str>,
limit: u32,
rest: &RestClient,
) -> Option {
use nostr::{Alphabet, SingleLetterTag};
// Defense-in-depth: validate hex event ID.
- if root_event_id.is_empty()
- || root_event_id.len() != 64
- || !root_event_id.chars().all(|c| c.is_ascii_hexdigit())
- {
+ if !is_valid_event_id_hex(root_event_id) {
tracing::warn!(
channel_id = %channel_id,
"invalid root_event_id (expected 64 hex chars) β skipping thread context fetch"
@@ -2033,7 +2020,7 @@ async fn fetch_thread_context(
let h_tag = SingleLetterTag::lowercase(Alphabet::H);
let ch_str = channel_id.to_string();
- // Two filters: (1) root event by ID, (2) replies with #e=root + #h=channel.
+ // Base filters: (1) root event by ID, (2) replies with #e=root + #h=channel.
let root_filter = nostr::Filter::new().id(nostr::EventId::from_hex(root_event_id).ok()?);
let replies_filter = nostr::Filter::new()
.kinds([
@@ -2043,14 +2030,27 @@ async fn fetch_thread_context(
.custom_tags(e_tag, [root_event_id])
.custom_tags(h_tag, [ch_str.as_str()])
.limit(limit as usize);
+ let mut filters = vec![root_filter, replies_filter];
+
+ if let Some(agent_reply_event_id) = agent_reply_event_id {
+ if is_valid_event_id_hex(agent_reply_event_id) && agent_reply_event_id != root_event_id {
+ if let Ok(event_id) = nostr::EventId::from_hex(agent_reply_event_id) {
+ // A selected task anchor may be outside the bounded prompt
+ // window. Fetch it independently, but require the same
+ // channel and thread root so forged client tags cannot route
+ // replies into a different conversation.
+ filters.push(
+ nostr::Filter::new()
+ .id(event_id)
+ .custom_tags(e_tag, [root_event_id])
+ .custom_tags(h_tag, [ch_str.as_str()]),
+ );
+ }
+ }
+ }
fetch_with_retry(|| async {
- match timeout(
- CONTEXT_FETCH_TIMEOUT,
- rest.query(&[root_filter.clone(), replies_filter.clone()]),
- )
- .await
- {
+ match timeout(CONTEXT_FETCH_TIMEOUT, rest.query(&filters)).await {
Ok(Ok(json)) => parse_nostr_thread_response(json, root_event_id),
Ok(Err(e)) => {
tracing::warn!(
@@ -2073,6 +2073,10 @@ async fn fetch_thread_context(
.await
}
+fn is_valid_event_id_hex(event_id: &str) -> bool {
+ event_id.len() == 64 && event_id.chars().all(|c| c.is_ascii_hexdigit())
+}
+
/// Fetch DM context via Nostr query: recent messages in channel by `#h` tag.
async fn fetch_dm_context(
channel_id: Uuid,
@@ -2195,6 +2199,11 @@ fn parse_dm_response(json: serde_json::Value, limit: u32) -> Option Option {
+ let event_id = obj
+ .get("id")
+ .or_else(|| obj.get("event_id"))
+ .and_then(|v| v.as_str())
+ .map(str::to_string);
let content = obj.get("content").and_then(|v| v.as_str())?;
let pubkey = obj
.get("pubkey")
@@ -2215,6 +2224,7 @@ fn json_to_context_message(obj: &serde_json::Value) -> Option {
.unwrap_or_else(|| "unknown".to_string());
Some(ContextMessage {
+ event_id,
pubkey: pubkey.to_string(),
timestamp,
content: content.to_string(),
@@ -2232,13 +2242,16 @@ fn parse_nostr_thread_response(
let events = json.as_array()?;
let mut root_msg = None;
let mut reply_msgs = Vec::new();
+ let mut seen_reply_ids = HashSet::new();
for ev in events {
let ev_id = ev.get("id").and_then(|v| v.as_str()).unwrap_or("");
if let Some(msg) = json_to_context_message(ev) {
if ev_id == root_event_id {
root_msg = Some(msg);
- } else {
+ } else if event_references_thread_root(ev, root_event_id)
+ && seen_reply_ids.insert(ev_id.to_string())
+ {
reply_msgs.push((
ev.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
msg,
@@ -2268,6 +2281,19 @@ fn parse_nostr_thread_response(
})
}
+fn event_references_thread_root(ev: &serde_json::Value, root_event_id: &str) -> bool {
+ ev.get("tags")
+ .and_then(|tags| tags.as_array())
+ .is_some_and(|tags| {
+ tags.iter().any(|tag| {
+ tag.as_array().is_some_and(|parts| {
+ parts.first().and_then(|part| part.as_str()) == Some("e")
+ && parts.get(1).and_then(|part| part.as_str()) == Some(root_event_id)
+ })
+ })
+ })
+}
+
/// Parse a Nostr query response (array of events) into DM context.
///
/// Events arrive in relay order (newest first); reversed to chronological.
@@ -2361,67 +2387,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
@@ -2506,178 +2472,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 {
@@ -2901,6 +2696,81 @@ mod tests {
assert!(parse_thread_response(json).is_none());
}
+ #[test]
+ fn test_parse_nostr_thread_response_keeps_independently_fetched_anchor() {
+ let root_id = "a".repeat(64);
+ let reply_id = "b".repeat(64);
+ let anchor_id = "c".repeat(64);
+ let json = json!([
+ {
+ "id": anchor_id.clone(),
+ "pubkey": "agent",
+ "content": "selected task anchor",
+ "created_at": 1710518520_u64,
+ "tags": [["e", root_id.clone(), "", "reply"]]
+ },
+ {
+ "id": root_id.clone(),
+ "pubkey": "human",
+ "content": "root message",
+ "created_at": 1710518400_u64,
+ "tags": []
+ },
+ {
+ "id": reply_id.clone(),
+ "pubkey": "agent",
+ "content": "visible reply",
+ "created_at": 1710518460_u64,
+ "tags": [["e", root_id.clone(), "", "reply"]]
+ }
+ ]);
+
+ let ctx = parse_nostr_thread_response(json, &root_id).expect("should parse");
+
+ match ctx {
+ ConversationContext::Thread { messages, .. } => {
+ assert_eq!(messages.len(), 3);
+ assert_eq!(messages[0].content, "root message");
+ assert_eq!(messages[1].content, "visible reply");
+ assert_eq!(messages[2].content, "selected task anchor");
+ }
+ _ => panic!("expected Thread context"),
+ }
+ }
+
+ #[test]
+ fn test_parse_nostr_thread_response_rejects_wrong_root_anchor() {
+ let root_id = "a".repeat(64);
+ let forged_anchor_id = "c".repeat(64);
+ let other_root_id = "d".repeat(64);
+ let json = json!([
+ {
+ "id": root_id.clone(),
+ "pubkey": "human",
+ "content": "root message",
+ "created_at": 1710518400_u64,
+ "tags": []
+ },
+ {
+ "id": forged_anchor_id.clone(),
+ "pubkey": "agent",
+ "content": "wrong thread",
+ "created_at": 1710518520_u64,
+ "tags": [["e", other_root_id.clone(), "", "reply"]]
+ }
+ ]);
+
+ let ctx = parse_nostr_thread_response(json, &root_id).expect("should parse");
+
+ match ctx {
+ ConversationContext::Thread { messages, .. } => {
+ assert_eq!(messages.len(), 1);
+ assert_eq!(messages[0].content, "root message");
+ }
+ _ => panic!("expected Thread context"),
+ }
+ }
+
#[test]
fn test_parse_dm_response_basic() {
let json = json!({
@@ -3066,6 +2936,7 @@ mod tests {
};
let context = ConversationContext::Thread {
messages: vec![ContextMessage {
+ event_id: None,
pubkey: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(),
timestamp: "2026-03-25T05:51:25Z".into(),
content: "follow up".into(),
@@ -3146,40 +3017,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 2fb1acfd2..94b72d031 100644
--- a/crates/buzz-acp/src/queue.rs
+++ b/crates/buzz-acp/src/queue.rs
@@ -559,7 +559,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
@@ -769,6 +769,9 @@ pub struct ThreadTags {
pub root_event_id: Option,
/// Parent event ID (hex). For direct replies to root, equals root.
pub parent_event_id: Option,
+ /// Dedicated agent-conversation anchor, when a client asks follow-ups to
+ /// stay attached to a selected task instead of the flat thread root.
+ pub agent_reply_event_id: Option,
/// Mentioned pubkeys from `p` tags (hex).
pub mentioned_pubkeys: Vec,
}
@@ -779,6 +782,8 @@ pub struct ThreadTags {
/// - Find an `e` tag with `root` marker β its value is `root_event_id`
/// - Find an `e` tag with `reply` marker β its value is `parent_event_id`
/// - If only `reply` marker found (direct reply to root), root == parent
+/// - Find a client task marker (`["client", "agent-conversation", id]`) β
+/// its value is `agent_reply_event_id`
/// - `p` tags β mentioned pubkeys
///
/// NOTE: Only handles NIP-10 marker-based format (preferred). The deprecated
@@ -787,6 +792,7 @@ pub struct ThreadTags {
pub fn parse_thread_tags(event: &Event) -> ThreadTags {
let mut root = None;
let mut reply = None;
+ let mut agent_reply = None;
let mut mentions = Vec::new();
for tag in event.tags.iter() {
@@ -804,6 +810,12 @@ pub fn parse_thread_tags(event: &Event) -> ThreadTags {
Some("p") if parts.len() >= 2 => {
mentions.push(parts[1].clone());
}
+ Some("client")
+ if parts.len() >= 3
+ && parts.get(1).map(|value| value.as_str()) == Some("agent-conversation") =>
+ {
+ agent_reply = parts.get(2).cloned();
+ }
_ => {}
}
}
@@ -820,6 +832,7 @@ pub fn parse_thread_tags(event: &Event) -> ThreadTags {
ThreadTags {
root_event_id,
parent_event_id,
+ agent_reply_event_id: agent_reply,
mentioned_pubkeys: mentions,
}
}
@@ -923,6 +936,7 @@ pub enum ConversationContext {
/// A single message in a conversation context section.
#[derive(Debug, Clone)]
pub struct ContextMessage {
+ pub event_id: Option,
pub pubkey: String,
pub timestamp: String,
pub content: String,
@@ -1061,6 +1075,9 @@ pub(crate) fn format_event_block(
if let Some(ref r) = thread.root_event_id {
parsed_parts.push(format!("root={r}"));
}
+ if let Some(ref agent_reply) = thread.agent_reply_event_id {
+ parsed_parts.push(format!("agent_reply={agent_reply}"));
+ }
if !thread.mentioned_pubkeys.is_empty() {
parsed_parts.push(format!(
"mentions=[{}]",
@@ -1081,15 +1098,15 @@ pub(crate) 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."
));
}
@@ -1136,11 +1153,37 @@ fn turn_is_human_facing(
thread_tags.mentioned_pubkeys.iter().any(|pk| !is_agent(pk))
}
+fn context_contains_event_id(context: Option<&ConversationContext>, event_id: &str) -> bool {
+ let messages = match context {
+ Some(ConversationContext::Thread { messages, .. })
+ | Some(ConversationContext::Dm { messages, .. }) => messages,
+ None => return false,
+ };
+
+ messages
+ .iter()
+ .any(|message| message.event_id.as_deref() == Some(event_id))
+}
+
+fn resolve_valid_agent_conversation_anchor(
+ thread_tags: &ThreadTags,
+ conversation_context: Option<&ConversationContext>,
+) -> Option {
+ let agent_reply_event_id = thread_tags.agent_reply_event_id.as_deref()?;
+ if context_contains_event_id(conversation_context, agent_reply_event_id) {
+ return Some(agent_reply_event_id.to_string());
+ }
+
+ None
+}
+
/// Resolve the `--reply-to` anchor for a non-DM turn.
///
/// Returns `Some(id)` only for human-facing turns (see [`turn_is_human_facing`]):
-/// - in a thread β the thread ROOT, keeping the reply flat at layer 1
-/// - top-level β the triggering event id, which becomes the new thread root
+/// - dedicated task β the selected agent reply anchor, after validating it
+/// exists in the fetched context for this thread/task
+/// - in a thread β the thread ROOT, keeping the reply flat at layer 1
+/// - top-level β the triggering event id, which becomes the new thread root
///
/// Returns `None` for agentβagent turns, leaving the agent free to nest deeply
/// (intentional for agent coordination).
@@ -1148,15 +1191,15 @@ fn resolve_reply_anchor(
sender_pubkey: &str,
thread_tags: &ThreadTags,
triggering_event_id: &str,
+ conversation_context: Option<&ConversationContext>,
profile_lookup: Option<&PromptProfileLookup>,
) -> Option {
if !turn_is_human_facing(sender_pubkey, thread_tags, profile_lookup) {
return None;
}
Some(
- thread_tags
- .root_event_id
- .clone()
+ resolve_valid_agent_conversation_anchor(thread_tags, conversation_context)
+ .or_else(|| thread_tags.root_event_id.clone())
.unwrap_or_else(|| triggering_event_id.to_string()),
)
}
@@ -1375,6 +1418,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec) -> Vec, mentions: &[&str]) -> ThreadTags {
ThreadTags {
+ agent_reply_event_id: None,
root_event_id: root.map(str::to_string),
parent_event_id: root.map(str::to_string),
mentioned_pubkeys: mentions.iter().map(|s| s.to_string()).collect(),
@@ -3118,7 +3186,7 @@ mod tests {
fn test_anchor_human_in_thread_uses_root() {
// Human asks inside a thread β anchor to the thread ROOT (flat at L1).
let tags = thread_tags(Some(ROOT_ID), &[AGENT_A_PK]);
- let anchor = resolve_reply_anchor(HUMAN_PK, &tags, TRIGGER_ID, Some(&id_lookup()));
+ let anchor = resolve_reply_anchor(HUMAN_PK, &tags, TRIGGER_ID, None, Some(&id_lookup()));
assert_eq!(anchor.as_deref(), Some(ROOT_ID));
}
@@ -3126,7 +3194,7 @@ mod tests {
fn test_anchor_human_top_level_uses_triggering_event() {
// Human top-level mention (no thread tags) β triggering event is root.
let tags = thread_tags(None, &[AGENT_A_PK]);
- let anchor = resolve_reply_anchor(HUMAN_PK, &tags, TRIGGER_ID, Some(&id_lookup()));
+ let anchor = resolve_reply_anchor(HUMAN_PK, &tags, TRIGGER_ID, None, Some(&id_lookup()));
assert_eq!(anchor.as_deref(), Some(TRIGGER_ID));
}
@@ -3134,14 +3202,14 @@ mod tests {
fn test_anchor_agent_to_agent_in_thread_is_none() {
// Agent pings agent inside a thread β no forced anchor (deep nesting ok).
let tags = thread_tags(Some(ROOT_ID), &[AGENT_B_PK]);
- let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, TRIGGER_ID, Some(&id_lookup()));
+ let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, TRIGGER_ID, None, Some(&id_lookup()));
assert_eq!(anchor, None);
}
#[test]
fn test_anchor_agent_to_agent_top_level_is_none() {
let tags = thread_tags(None, &[AGENT_B_PK]);
- let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, TRIGGER_ID, Some(&id_lookup()));
+ let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, TRIGGER_ID, None, Some(&id_lookup()));
assert_eq!(anchor, None);
}
@@ -3149,7 +3217,7 @@ mod tests {
fn test_anchor_agent_sender_but_human_tagged_flattens() {
// Agent-authored, but a human is tagged β human-facing β anchor to root.
let tags = thread_tags(Some(ROOT_ID), &[AGENT_B_PK, HUMAN_PK]);
- let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, TRIGGER_ID, Some(&id_lookup()));
+ let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, TRIGGER_ID, None, Some(&id_lookup()));
assert_eq!(anchor.as_deref(), Some(ROOT_ID));
}
@@ -3157,7 +3225,7 @@ mod tests {
fn test_anchor_unknown_identity_treated_as_human() {
// No profile lookup β fail open (treat as human so visibility is kept).
let tags = thread_tags(Some(ROOT_ID), &[]);
- let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, TRIGGER_ID, None);
+ let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, TRIGGER_ID, None, None);
assert_eq!(anchor.as_deref(), Some(ROOT_ID));
}
@@ -3166,7 +3234,7 @@ mod tests {
// Raw p-tag presence must NOT flatten when every tagged pubkey is an
// agent β this is the regression Pinky flagged.
let tags = thread_tags(Some(ROOT_ID), &[AGENT_A_PK, AGENT_B_PK]);
- let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, TRIGGER_ID, Some(&id_lookup()));
+ let anchor = resolve_reply_anchor(AGENT_A_PK, &tags, TRIGGER_ID, None, Some(&id_lookup()));
assert_eq!(anchor, None);
}
@@ -3217,6 +3285,7 @@ mod tests {
// Thread context fetched (as the fetch path does for DM replies).
let ctx = ConversationContext::Thread {
messages: vec![ContextMessage {
+ event_id: None,
pubkey: "npub1xyz".into(),
timestamp: "2026-03-15T16:30:00Z".into(),
content: "Should I deploy?".into(),
@@ -3743,15 +3812,128 @@ mod tests {
);
}
+ #[test]
+ fn test_reply_instruction_prefers_agent_conversation_anchor() {
+ let ch = Uuid::new_v4();
+ let root_id = "a".repeat(64);
+ let parent_id = "b".repeat(64);
+ let agent_reply_id = "c".repeat(64);
+ let event = make_event_with_tags(
+ "@bot keep going",
+ vec![
+ vec!["e".into(), root_id.clone(), "".into(), "root".into()],
+ vec!["e".into(), parent_id.clone(), "".into(), "reply".into()],
+ vec![
+ "client".into(),
+ "agent-conversation".into(),
+ agent_reply_id.clone(),
+ ],
+ ],
+ );
+ let batch = FlushBatch {
+ channel_id: ch,
+ events: vec![BatchEvent {
+ event,
+ prompt_tag: "@mention".into(),
+ received_at: Instant::now(),
+ }],
+ cancelled_events: vec![],
+ cancel_reason: None,
+ };
+ let ctx = ConversationContext::Thread {
+ messages: vec![ContextMessage {
+ event_id: Some(agent_reply_id.clone()),
+ pubkey: "agent".into(),
+ timestamp: "2026-03-15T16:30:00Z".into(),
+ content: "I'll take this into a task.".into(),
+ }],
+ total: 1,
+ truncated: false,
+ };
+
+ let prompt = format_prompt(
+ &batch,
+ &FormatPromptArgs {
+ conversation_context: Some(&ctx),
+ ..Default::default()
+ },
+ )
+ .join("\n\n");
+ assert!(
+ prompt.contains(&format!("--reply-to {agent_reply_id}")),
+ "dedicated task follow-up should anchor agent reply to the selected task"
+ );
+ assert!(
+ !prompt.contains(&format!("--reply-to {root_id}")),
+ "dedicated task follow-up should not fall back to the flat thread root"
+ );
+ }
+
+ #[test]
+ fn test_reply_instruction_rejects_unvalidated_agent_conversation_anchor() {
+ let ch = Uuid::new_v4();
+ let root_id = "a".repeat(64);
+ let parent_id = "b".repeat(64);
+ let forged_agent_reply_id = "c".repeat(64);
+ let event = make_event_with_tags(
+ "@bot keep going",
+ vec![
+ vec!["e".into(), root_id.clone(), "".into(), "root".into()],
+ vec!["e".into(), parent_id.clone(), "".into(), "reply".into()],
+ vec![
+ "client".into(),
+ "agent-conversation".into(),
+ forged_agent_reply_id.clone(),
+ ],
+ ],
+ );
+ let batch = FlushBatch {
+ channel_id: ch,
+ events: vec![BatchEvent {
+ event,
+ prompt_tag: "@mention".into(),
+ received_at: Instant::now(),
+ }],
+ cancelled_events: vec![],
+ cancel_reason: None,
+ };
+ let ctx = ConversationContext::Thread {
+ messages: vec![ContextMessage {
+ event_id: Some(parent_id),
+ pubkey: "human".into(),
+ timestamp: "2026-03-15T16:30:00Z".into(),
+ content: "Keep going.".into(),
+ }],
+ total: 1,
+ truncated: false,
+ };
+
+ let prompt = format_prompt(
+ &batch,
+ &FormatPromptArgs {
+ conversation_context: Some(&ctx),
+ ..Default::default()
+ },
+ )
+ .join("\n\n");
+ assert!(
+ prompt.contains(&format!("--reply-to {root_id}")),
+ "unvalidated task anchors should fall back to the flat thread root"
+ );
+ assert!(
+ !prompt.contains(&format!("--reply-to {forged_agent_reply_id}")),
+ "forged task anchors must not route the agent into another thread"
+ );
+ }
+
#[test]
fn test_reply_instruction_present_for_dm_thread_reply() {
let ch = Uuid::new_v4();
let root_id = "b".repeat(64);
let event = make_event_with_tags(
"thanks",
- vec![vec!["e".into(), root_id, "".into(), "reply".into()]],
+ vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]],
);
- let event_id = event.id.to_hex();
let batch = FlushBatch {
channel_id: ch,
events: vec![BatchEvent {
@@ -3776,7 +3958,7 @@ mod tests {
)
.join("\n\n");
assert!(
- prompt.contains(&format!("--reply-to {event_id}")),
+ prompt.contains(&format!("--reply-to {root_id}")),
"DM thread reply should include reply instruction"
);
}
diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs
index 0f26fc5d4..815b65c48 100644
--- a/crates/buzz-acp/src/relay.rs
+++ b/crates/buzz-acp/src/relay.rs
@@ -161,8 +161,7 @@ fn merge_discovered_channels(
/// Extracted from `HarnessRelay` fields so it can be shared (via `Arc`) with
/// spawned prompt tasks without giving them access to the WebSocket.
///
-/// All reads go through `POST /query` with NIP-98 auth. Event submission goes
-/// through `POST /events` with NIP-98 auth.
+/// All reads go through `POST /query` with NIP-98 auth.
#[derive(Debug, Clone)]
pub struct RestClient {
pub http: reqwest::Client,
@@ -338,23 +337,6 @@ impl RestClient {
.await
.map_err(|e| RelayError::Http(e.to_string()))
}
-
- /// Submit a signed event via the HTTP bridge: `POST /events` with NIP-98 auth.
- ///
- /// The event must already be signed. Returns the relay response JSON.
- pub async fn submit_event(&self, event: &Event) -> 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 f2e918424..abf31dc27 100644
--- a/crates/buzz-core/src/kind.rs
+++ b/crates/buzz-core/src/kind.rs
@@ -297,6 +297,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.).
@@ -486,6 +488,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 e41d2717f..4cbfca4a0 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,
@@ -182,6 +182,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite),
@@ -391,6 +392,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 69f19f664..06e4e9f4b 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -60,7 +60,10 @@ const overrides = new Map([
// config-bridge: get_agent_config_surface/write_agent_config_field/put_agent_session_config
// commands add ~40 lines. Queued to split.
// branch cut; override bumped to cover the merged total. Queued to split.
- ["src-tauri/src/commands/agents.rs", 1437],
+ // continued-agent-conversations: refreshes the owner auth tag before
+ // starting/restoring/deploying agents so staged identities keep working.
+ // latest-main rebase adds the config-bridge and task-review fixes together.
+ ["src-tauri/src/commands/agents.rs", 1467],
// 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
@@ -73,7 +76,12 @@ const overrides = new Map([
// unify refactor followup. +26 for resolve_effective_prompt_model_provider
// re-introduced after 826d735fe removal (config-bridge caller still needs it).
// PGID resolution helper + PID-recycling safety guard added for orphan sweep.
- ["src-tauri/src/managed_agents/runtime.rs", 2150],
+ // continued-agent-conversations: owner-scoped auth tag refresh is threaded
+ // through the runtime env builder and covered by regression tests.
+ // latest-main rebase adds the config-bridge and task-review fixes together.
+ // latest main added runtime restore plumbing on top of the task anchor review fixes.
+ ["src-tauri/src/managed_agents/runtime.rs", 2174],
+ ["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
// (reconcile_inbound_tombstone), the two apply_inbound_* fns, the
@@ -82,11 +90,12 @@ const overrides = new Map([
// queued to split with the list. The two `agents-data-changed` emits (live
// UI refresh on inbound reconcile + tombstone) add the latest growth.
["src-tauri/src/commands/personas.rs", 1279],
+ ["src-tauri/src/managed_agents/persona_card.rs", 1050],
// applyWorkspace reposDir parameter plus the validateReposDir binding,
// threaded through Tauri invokes for configurable repos_dir, plus the
// harness-persona-sync `harnessOverride` create-input bit β load-bearing
- // parameter plumbing, not generic debt growth. Approved override; still
- // queued to split.
+ // parameter plumbing, plus continued-agent-conversations client task-anchor
+ // tags on message sends. Approved override; still queued to split.
["src/shared/api/tauri.ts", 1235],
// harness-persona-sync feature growth, queued to split in the resolver-unify
// refactor followup. discovery.rs is dominated by the new test module
@@ -95,7 +104,11 @@ const overrides = new Map([
// agents keep an installed runtime alias when the primary command is absent.
// Load-bearing, not generic debt.
// config-bridge: schema-driven field extraction adds ~26 lines. Queued to split.
- ["src-tauri/src/managed_agents/discovery.rs", 1111],
+ // latest-main rebase adds the config-bridge and task-review fixes together.
+ ["src-tauri/src/managed_agents/discovery.rs", 1131],
+ // types.rs adds the persona/instance harness fields. Load-bearing, not
+ // generic debt.
+ ["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
// crash-safe 0o600 fallback). Load-bearing security + feature coverage, not
@@ -115,6 +128,24 @@ const overrides = new Map([
// +135 for AgentInfoFocusedView/DiagnosticsFocusedView/ChannelsFocusedView
// props restored after 826d735fe removal (UserProfilePanel.tsx still needs them).
["src/features/profile/ui/UserProfilePanelSections.tsx", 1140],
+ // 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.
+ // 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: channel task/message surface routing is
+ // threaded through the screen while the pane split follow-up is pending.
+ ["src/features/channels/ui/ChannelScreen.tsx", 1027],
+ // 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
@@ -136,7 +167,8 @@ const overrides = new Map([
// catalog module; agent_models.rs retains the thin wrapper (~50 lines).
// File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess
// fallback. Queued to split into dedicated discovery modules.
- ["src-tauri/src/commands/agent_models.rs", 1066],
+ // latest main rebase adds the provider fallback guard.
+ ["src-tauri/src/commands/agent_models.rs", 1068],
]);
await runFileSizeCheck({
diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs
index 1983cfbd0..454336bdb 100644
--- a/desktop/src-tauri/src/commands/agent_config.rs
+++ b/desktop/src-tauri/src/commands/agent_config.rs
@@ -176,6 +176,7 @@ pub async fn get_agent_config_surface(
record.persona_id.as_deref(),
&personas,
record.agent_command_override.as_deref(),
+ Some(&record.agent_command),
);
let runtime_meta = known_acp_runtime(&effective_cmd);
let session_cache = state.get_session_cache(&pubkey);
diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs
index cef2e7115..faa08e3b9 100644
--- a/desktop/src-tauri/src/commands/agent_models.rs
+++ b/desktop/src-tauri/src/commands/agent_models.rs
@@ -66,6 +66,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());
@@ -929,6 +930,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/agent_profile_reconcile.rs b/desktop/src-tauri/src/commands/agent_profile_reconcile.rs
new file mode 100644
index 000000000..516c2cd6a
--- /dev/null
+++ b/desktop/src-tauri/src/commands/agent_profile_reconcile.rs
@@ -0,0 +1,32 @@
+use tauri::AppHandle;
+
+use crate::{app_state::AppState, managed_agents::load_managed_agents};
+
+use super::agents::ProfileReconcileData;
+
+pub(super) fn refresh_auth_tag(
+ app: &AppHandle,
+ state: &AppState,
+ pubkey: &str,
+ data: &mut ProfileReconcileData,
+) {
+ let result = (|| -> 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 03e8a1786..ec3c78d93 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -241,6 +241,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()
@@ -254,6 +259,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
@@ -655,6 +668,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,
@@ -976,6 +990,10 @@ pub async fn start_managed_agent(
// Snapshot the workspace owner pubkey for the legacy auth_tag fallback.
// Read outside the records lock to keep lock ordering simple.
let owner_hex = workspace_owner_hex(&state)?;
+ let refreshed_auth_tag = {
+ let owner_keys = state.keys.lock().map_err(|error| error.to_string())?;
+ crate::managed_agents::managed_agent_auth_tag_for_owner(&owner_keys, &pubkey)?
+ };
enum StartTarget {
Local,
Provider {
@@ -986,8 +1004,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()
@@ -1000,14 +1017,21 @@ pub async fn start_managed_agent(
let (sync_changed, exited_pubkeys) =
sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app));
- if sync_changed {
- save_managed_agents(&app, &records)?;
- }
+ let mut records_changed = sync_changed;
for pubkey in &exited_pubkeys {
state.clear_session_cache(pubkey);
}
let record = find_managed_agent_mut(&mut records, &pubkey)?;
+ 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();
+ records_changed = true;
+ }
// Resolve the effective harness for the avatar-fallback derivation in
// profile reconcile (the create-time snapshot may be empty or stale for
@@ -1017,6 +1041,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 {
@@ -1040,6 +1065,10 @@ pub async fn start_managed_agent(
}
};
+ if records_changed {
+ save_managed_agents(&app, &records)?;
+ }
+
(target, reconcile)
};
@@ -1088,9 +1117,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/messages.rs b/desktop/src-tauri/src/commands/messages.rs
index 2c8dac5a4..1b28a5f5f 100644
--- a/desktop/src-tauri/src/commands/messages.rs
+++ b/desktop/src-tauri/src/commands/messages.rs
@@ -283,6 +283,7 @@ pub async fn send_channel_message(
media_tags: Option>>,
emoji_tags: Option>>,
mention_tags: Option>>,
+ client_tags: Option>>,
mention_pubkeys: Option>,
kind: Option,
state: State<'_, AppState>,
@@ -294,7 +295,11 @@ pub async fn send_channel_message(
let media = media_tags.unwrap_or_default();
let emoji = emoji_tags.unwrap_or_default();
let mention_refs_only = mention_tags.unwrap_or_default();
+ let client = client_tags.unwrap_or_default();
let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE);
+ if kind_num != buzz_core_pkg::kind::KIND_STREAM_MESSAGE && !client.is_empty() {
+ return Err("client tags are only supported on stream messages".into());
+ }
let mut resolved_root: Option = None;
@@ -330,7 +335,7 @@ pub async fn send_channel_message(
}
None => None,
};
- events::build_message(
+ events::build_message_with_client_tags(
channel_uuid,
content.trim(),
thread_ref.as_ref(),
@@ -338,6 +343,7 @@ pub async fn send_channel_message(
&media,
&emoji,
&mention_refs_only,
+ &client,
)?
}
};
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 445fe2956..9959583a3 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -1,6 +1,7 @@
mod agent_config;
mod agent_discovery;
mod agent_models;
+mod agent_profile_reconcile;
mod agent_settings;
mod agents;
mod canvas;
diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs
index 59c6dee99..d6322b863 100644
--- a/desktop/src-tauri/src/managed_agents/discovery.rs
+++ b/desktop/src-tauri/src/managed_agents/discovery.rs
@@ -279,11 +279,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)
@@ -298,6 +301,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)
}
@@ -320,7 +329,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),
@@ -373,7 +382,7 @@ pub fn create_time_agent_command_override(
let picked = picked_command
.map(str::trim)
.filter(|value| !value.is_empty())?;
- let inherited_command = effective_agent_command(persona_id, personas, None);
+ let inherited_command = effective_agent_command(persona_id, personas, None, None);
return (picked != inherited_command).then(|| picked.to_string());
}
@@ -927,7 +936,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"
);
}
@@ -937,7 +946,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"
);
}
@@ -947,26 +956,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 85f539112..44216d663 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 585ffea26..c45284a0e 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -1517,6 +1517,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)
@@ -1611,12 +1612,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 {
@@ -1629,6 +1628,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}"))
+}
+
/// Spawn an agent process without holding any locks on records or runtimes.
/// Returns the child process and log path on success. The caller is responsible
/// for updating `ManagedAgentRecord` fields and inserting into the runtimes map.
@@ -1668,6 +1714,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)
@@ -1822,7 +1869,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 cc9857205..90e2ec1d8 100644
--- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs
@@ -159,8 +159,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),
@@ -172,6 +180,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 8c1a21a32..0dcff4095 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -18,6 +18,8 @@ import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotific
import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems";
import { useTauriWindowDrag } from "@/app/useTauriWindowDrag";
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
+import { AgentConversationScreen } from "@/features/agents/ui/AgentConversationScreen";
+import { useAgentConversationShellState } from "@/features/agents/useAgentConversationShellState";
import {
channelsQueryKey,
useChannelsQuery,
@@ -133,14 +135,13 @@ 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;
+ 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 +197,26 @@ export function AppShell() {
? (channels.find((channel) => channel.id === targetChannelId) ?? null)
: null;
}, [channels, managedChannelId, selectedChannelId]);
+ 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,
@@ -421,9 +442,17 @@ export function AppShell() {
const handleOpenSearchResult = React.useCallback(
(hit: SearchHit) => {
+ clearSelectedAgentConversation();
void openSearchHit(hit);
},
- [openSearchHit],
+ [clearSelectedAgentConversation, openSearchHit],
+ );
+ const handleSelectChannel = React.useCallback(
+ (channelId: string) => {
+ clearSelectedAgentConversation();
+ void goChannel(channelId);
+ },
+ [clearSelectedAgentConversation, goChannel],
);
// Prevent webview file:/// navigation on file drop outside the composer.
@@ -569,9 +598,12 @@ export function AppShell() {
{
setManagedChannelId(
@@ -663,6 +695,7 @@ export function AppShell() {
setIsAddWorkspaceOpen(true)}
onUpdateWorkspace={workspacesHook.updateWorkspace}
@@ -713,6 +747,7 @@ export function AppShell() {
createdChannel.id,
name,
);
+ clearSelectedAgentConversation();
await goChannel(createdChannel.id);
void applyAgents(templateId, createdChannel.id);
}}
@@ -737,6 +772,7 @@ export function AppShell() {
createdForum.id,
name,
);
+ clearSelectedAgentConversation();
await goChannel(createdForum.id);
void applyAgents(templateId, createdForum.id);
}}
@@ -750,20 +786,37 @@ export function AppShell() {
await openDmMutation.mutateAsync({
pubkeys,
});
+ clearSelectedAgentConversation();
await goChannel(directMessage.id);
}}
- onSelectAgents={() => void goAgents()}
- onSelectChannel={(channelId) =>
- void goChannel(channelId)
+ onSelectAgentConversation={
+ handleSelectAgentConversation
}
+ onSelectAgents={() => {
+ clearSelectedAgentConversation();
+ void goAgents();
+ }}
+ onSelectChannel={handleSelectChannel}
onOpenSearchResult={handleOpenSearchResult}
searchChannels={channels}
searchFocusRequest={searchFocusRequest}
- onSelectHome={() => void goHome()}
- onSelectProjects={() => void goProjects()}
- onSelectPulse={() => void goPulse()}
+ onSelectHome={() => {
+ clearSelectedAgentConversation();
+ void goHome();
+ }}
+ onSelectProjects={() => {
+ clearSelectedAgentConversation();
+ void goProjects();
+ }}
+ onSelectPulse={() => {
+ clearSelectedAgentConversation();
+ void goPulse();
+ }}
onSelectSettings={handleOpenSettings}
- onSelectWorkflows={() => void goWorkflows()}
+ onSelectWorkflows={() => {
+ clearSelectedAgentConversation();
+ void goWorkflows();
+ }}
onSetPresenceStatus={(status) =>
presenceSession.setStatus(status)
}
@@ -785,6 +838,9 @@ export function AppShell() {
: undefined
}
selectedChannelId={selectedChannelId}
+ selectedAgentConversationId={
+ selectedAgentConversationId
+ }
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
unreadChannelCounts={unreadChannelCounts}
@@ -805,7 +861,19 @@ export function AppShell() {
-
+ {selectedAgentConversation ? (
+
+ ) : (
+
+ )}
@@ -828,11 +896,10 @@ export function AppShell() {
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
setManagedChannelId(null);
+ clearSelectedAgentConversation();
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..4f0eff1d7
--- /dev/null
+++ b/desktop/src/features/agents/agentConversations.test.mjs
@@ -0,0 +1,738 @@
+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";
+import {
+ buildAgentConversationTypingScopeIds,
+ isConversationMessage,
+} from "./ui/AgentConversationScreen.helpers.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 typing scope includes selected task messages", () => {
+ const root = message({
+ body: "Can you check the buttons?",
+ createdAt: 1,
+ id: "root",
+ });
+ const agentReply = message({
+ body: "I'll look.",
+ createdAt: 2,
+ id: "agent-reply",
+ pubkey: "agent",
+ });
+ const taskFollowUp = message({
+ body: "Can you include composer buttons?",
+ createdAt: 3,
+ id: "task-follow-up",
+ });
+ const conversation = buildAgentConversation({
+ agentName: "Fizz",
+ agentPubkey: "agent",
+ agentReply,
+ channel: { id: "channel", name: "design" },
+ contextMessages: [root, agentReply, taskFollowUp],
+ parentMessage: root,
+ threadRootMessage: root,
+ });
+
+ const scopeIds = buildAgentConversationTypingScopeIds(conversation, [
+ root,
+ agentReply,
+ taskFollowUp,
+ ]);
+
+ assert.equal(scopeIds.has("root"), true);
+ assert.equal(scopeIds.has("agent-reply"), true);
+ assert.equal(scopeIds.has("task-follow-up"), true);
+ assert.equal(scopeIds.has("other-thread-message"), false);
+});
+
+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: false, pubkey: "agent-two" },
+ { canMessage: false, pubkey: "agent-three" },
+ ]),
+ ["agent-one"],
+ );
+
+ assert.deepEqual(
+ getAutoRoutedAgentConversationPubkeys([
+ { canMessage: true, pubkey: "agent-one" },
+ { canMessage: true, pubkey: "agent-two" },
+ { canMessage: false, pubkey: "agent-three" },
+ ]),
+ [],
+ );
+
+ 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 workspaceScope = "wss://relay.example.com";
+ 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", 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);
+ });
+});
+
+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"]);
+});
+
+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 marker hides loaded task replies when anchor is outside the window", () => {
+ const root = message({
+ body: "Can you look into the data model?",
+ createdAt: 1,
+ id: "root",
+ });
+ const taskReply = message({
+ body: "This newer reply belongs in the dedicated conversation.",
+ createdAt: 5,
+ id: "task-reply",
+ pubkey: "agent",
+ });
+ const marker = parseAgentConversationMarker(
+ markerEvent({
+ content: { agentReplyId: "missing-reply", startedAt: 4 },
+ createdAt: 4,
+ }),
+ );
+
+ const hiddenIds = getHiddenAgentConversationMessageIds(
+ [root, taskReply],
+ marker ? [marker] : [],
+ );
+
+ assert.deepEqual([...hiddenIds], ["task-reply"]);
+});
+
+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"]);
+});
+
+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"]);
+});
+
+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/agentConversations.ts b/desktop/src/features/agents/agentConversations.ts
new file mode 100644
index 000000000..02eb23d86
--- /dev/null
+++ b/desktop/src/features/agents/agentConversations.ts
@@ -0,0 +1,739 @@
+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 = {
+ startedAt?: number | null;
+ 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;
+};
+
+function normalizeAgentConversationStorageScope(
+ workspaceScope: string | null | undefined,
+): string {
+ const normalizedScope = workspaceScope?.trim().replace(/\/+$/, "");
+ return normalizedScope || "unknown-workspace";
+}
+
+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(
+ participants: readonly AgentConversationRouteableParticipant[],
+): string[] {
+ const messageableParticipants = participants.filter(
+ (participant) => participant.canMessage,
+ );
+
+ if (messageableParticipants.length !== 1) {
+ return [];
+ }
+
+ return [messageableParticipants[0].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,
+ workspaceScope: string | null | undefined,
+): Set {
+ try {
+ const raw = window.localStorage.getItem(
+ hiddenAgentConversationsStorageKey(pubkey, workspaceScope),
+ );
+ 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,
+ workspaceScope: string | null | undefined,
+ ids: ReadonlySet,
+): void {
+ try {
+ window.localStorage.setItem(
+ hiddenAgentConversationsStorageKey(pubkey, workspaceScope),
+ 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,
+ workspaceScope: string | null | undefined,
+): AgentConversation[] {
+ try {
+ const raw = window.localStorage.getItem(
+ agentConversationsStorageKey(pubkey, workspaceScope),
+ );
+ 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,
+ workspaceScope: string | null | undefined,
+ 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, workspaceScope),
+ 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 =
+ typeof update.startedAt === "number" && Number.isFinite(update.startedAt)
+ ? update.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 messageById = new Map(
+ orderedMessages.map(({ message }) => [message.id, message]),
+ );
+ const anchorMessageIdsByThreadRootId = new Map>();
+ const cutoffByThreadRootId = new Map<
+ string,
+ {
+ anchorIndex: number | null;
+ startedAt: number;
+ }
+ >();
+ for (const marker of markers) {
+ const anchorMessage = messageById.get(marker.agentReplyId);
+ const anchorIndex = messageIndexById.get(marker.agentReplyId);
+ if (!anchorMessage || anchorIndex === undefined) {
+ const hasLoadedThreadContext = orderedMessages.some(({ message }) => {
+ const messageThreadRootId = message.rootId ?? message.parentId ?? null;
+ return (
+ message.id === marker.threadRootId ||
+ messageThreadRootId === marker.threadRootId
+ );
+ });
+ if (!hasLoadedThreadContext) {
+ continue;
+ }
+ } else {
+ 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: anchorIndex ?? null,
+ startedAt: marker.startedAt,
+ };
+ const isEarlier =
+ current === undefined ||
+ candidate.startedAt < current.startedAt ||
+ (candidate.startedAt === current.startedAt &&
+ candidate.anchorIndex !== null &&
+ current.anchorIndex !== null &&
+ candidate.anchorIndex < current.anchorIndex);
+ 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 ||
+ anchorMessageIdsByThreadRootId.get(threadRootId)?.has(message.id)
+ ) {
+ continue;
+ }
+
+ const messageIndex = messageIndexById.get(message.id);
+ if (messageIndex !== undefined && cutoff.anchorIndex !== null) {
+ 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..087c458f1
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentConversationScreen.helpers.ts
@@ -0,0 +1,420 @@
+import type {
+ AgentConversation,
+ AgentConversationMarker,
+} 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,
+ }));
+}
+
+export function buildAgentConversationTypingScopeIds(
+ conversation: AgentConversation,
+ messages: readonly TimelineMessage[],
+) {
+ const ids = new Set([
+ conversation.threadRootId,
+ conversation.agentReply.id,
+ ]);
+
+ for (const message of messages) {
+ ids.add(message.id);
+ }
+
+ return ids;
+}
+
+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,
+ markers: readonly AgentConversationMarker[] = [],
+ messages: readonly TimelineMessage[] = [],
+) {
+ if (
+ message.id === 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 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(
+ (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;
+ }
+ }
+
+ 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 =
+ 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
+ );
+}
+
+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..5f31411a9
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentConversationScreen.tsx
@@ -0,0 +1,841 @@
+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,
+ buildAgentConversationTypingScopeIds,
+ 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 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 {
+ getMessageReadAt,
+ isThreadMuted,
+ 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,
+ agentConversationMarkers,
+ formatted,
+ ),
+ );
+ 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,
+ agentConversationMarkers,
+ 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 typingScopeIds = React.useMemo(
+ () =>
+ buildAgentConversationTypingScopeIds(
+ conversation,
+ conversationSourceMessages,
+ ),
+ [conversation, conversationSourceMessages],
+ );
+ 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 == null ||
+ !typingScopeIds.has(entry.threadHeadId) ||
+ !agentPubkeys.has(normalized) ||
+ latestMessagePubkey === normalized ||
+ pubkeys.some((pubkey) => normalizePubkey(pubkey) === normalized)
+ ) {
+ continue;
+ }
+
+ pubkeys.push(
+ knownAgentParticipants.get(normalized)?.pubkey ?? entry.pubkey,
+ );
+ }
+
+ return pubkeys;
+ }, [
+ agentPubkeys,
+ knownAgentParticipants,
+ timelineMessages,
+ typingScopeIds,
+ 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,
+ },
+ {
+ startedAt: currentConversationMarker?.startedAt ?? null,
+ summary: currentConversationMarker?.summary ?? null,
+ summaryAuthorName: currentConversationMarker?.summaryAuthorName ?? null,
+ summaryAuthorPubkey:
+ currentConversationMarker?.summaryAuthorPubkey ?? null,
+ summaryCreatedAt: currentConversationMarker?.summaryCreatedAt ?? null,
+ },
+ ).catch((error) => {
+ console.warn("[agentConversations] title marker publish failed:", error);
+ });
+ }, [
+ conversation,
+ conversationSourceMessages,
+ currentConversationMarker?.startedAt,
+ currentConversationMarker?.summary,
+ currentConversationMarker?.summaryAuthorName,
+ currentConversationMarker?.summaryAuthorPubkey,
+ currentConversationMarker?.summaryCreatedAt,
+ updateAgentConversationTitle,
+ ]);
+ React.useEffect(() => {
+ 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,
+ 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
+ .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 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({
+ clientTags: [
+ ["client", "agent-conversation", conversation.agentReply.id],
+ ],
+ content,
+ mediaTags,
+ mentionPubkeys: buildAgentConversationMentionPubkeys({
+ autoRouteAgentPubkeys: autoRoutedAgentPubkeys,
+ mentionPubkeys,
+ }),
+ parentEventId: replyParentEventId,
+ });
+ },
+ [
+ autoRoutedAgentPubkeys,
+ conversation.agentReply.id,
+ replyParentEventId,
+ 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,
+ },
+ {
+ startedAt: currentConversationMarker?.startedAt ?? null,
+ 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,
+ currentConversationMarker?.startedAt,
+ 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}
+ layoutShiftKey={conversation.id}
+ 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/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/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
new file mode 100644
index 000000000..284b77e85
--- /dev/null
+++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
@@ -0,0 +1,206 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ canOpenAgentConversationInChannel,
+ 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({
+ channelType: "dm",
+ participantPubkeys: ["human", "agent-one", "human-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"],
+ );
+});
+
+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 a30ee2411..f56754435 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";
@@ -43,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,
@@ -51,3 +71,128 @@ 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;
+
+ const otherParticipants = new Map();
+ for (const pubkey of channel.participantPubkeys) {
+ const normalized = normalizePubkey(pubkey);
+ if (!normalized || normalized === normalizedCurrentPubkey) {
+ continue;
+ }
+
+ otherParticipants.set(normalized, pubkey);
+ }
+
+ if (otherParticipants.size !== 1) {
+ return [];
+ }
+
+ return singleKnownAgentPubkey(otherParticipants.values(), 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 556180830..1faa3e4cf 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -11,6 +11,7 @@ import {
MessageTimeline,
type MessageTimelineHandle,
} from "@/features/messages/ui/MessageTimeline";
+import { getHiddenAgentConversationMessageIds } from "@/features/agents/agentConversations";
import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay";
import {
getDmHuddleMemberPubkeys,
@@ -38,6 +39,7 @@ import {
type WelcomeComposerBannerState,
} from "@/features/channels/ui/WelcomeComposerBanner";
import {
+ canOpenAgentConversationInChannel,
getChannelIntroDescription,
getChannelIntroKind,
isWelcomeSetupSystemMessage,
@@ -51,11 +53,13 @@ import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRen
import type { TimelineMessage } from "@/features/messages/types";
import { isWelcomeChannel } from "@/features/onboarding/welcome";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
+import { useAppShell } from "@/app/AppShellContext";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
export const ChannelPane = React.memo(function ChannelPane({
activeChannel,
+ agentConversationMarkers,
agentPubkeys,
agentPubkeysPending = false,
agentSessionAgents,
@@ -139,6 +143,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);
@@ -236,17 +241,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 ||
@@ -317,6 +311,54 @@ 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 ||
+ !canOpenAgentConversationInChannel({
+ channel: activeChannel,
+ publishMarker: options?.publishMarker,
+ })
+ ) {
+ 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;
@@ -359,8 +401,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({
@@ -435,22 +498,92 @@ 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);
+ }
+ if (targetMessageId) {
+ hiddenIds.delete(targetMessageId);
+ }
+ if (threadScrollTargetId) {
+ hiddenIds.delete(threadScrollTargetId);
+ }
+ if (channelFind.activeMatch?.messageId) {
+ hiddenIds.delete(channelFind.activeMatch.messageId);
+ }
+ return hiddenIds;
+ }, [
+ agentConversationMarkers,
+ baseVisibleMessages,
+ channelFind.activeMatch?.messageId,
+ targetMessageId,
+ threadScrollTargetId,
+ 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),
@@ -569,6 +702,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/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts
index 02b441ff6..ac2cdc0ca 100644
--- a/desktop/src/features/channels/ui/ChannelPane.types.ts
+++ b/desktop/src/features/channels/ui/ChannelPane.types.ts
@@ -1,4 +1,5 @@
import type * as React from "react";
+import type { AgentConversationMarker } from "@/features/agents/agentConversations";
import type { BotActivityAgent } from "@/features/channels/ui/BotActivityBar";
import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
@@ -14,6 +15,7 @@ import type {
import type { Channel } from "@/shared/api/types";
export type ChannelPaneProps = {
activeChannel: Channel | null;
+ agentConversationMarkers?: readonly AgentConversationMarker[];
activityAgents?: BotActivityAgent[];
agentPubkeys?: ReadonlySet;
agentPubkeysPending?: boolean;
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index bfa0b9cc3..67c4929e4 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -4,6 +4,10 @@ import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers";
+import {
+ buildAgentConversationMarkers,
+ getHiddenAgentConversationMessageIds,
+} from "@/features/agents/agentConversations";
import {
useChannelMembersQuery,
useJoinChannelMutation,
@@ -18,6 +22,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,
@@ -437,6 +445,23 @@ export function ChannelScreen({
: [...currentEvents, event],
);
}, []);
+ const agentConversationMarkers = React.useMemo(
+ () => buildAgentConversationMarkers(resolvedMessages),
+ [resolvedMessages],
+ );
+ const unreadTimelineMessages = React.useMemo(() => {
+ const hiddenMessageIds = getHiddenAgentConversationMessageIds(
+ timelineMessages,
+ agentConversationMarkers,
+ );
+ if (hiddenMessageIds.size === 0) {
+ return timelineMessages;
+ }
+
+ return timelineMessages.filter(
+ (message) => !hiddenMessageIds.has(message.id),
+ );
+ }, [agentConversationMarkers, timelineMessages]);
const channelFind = useChannelFind({
channelId: activeChannelId,
messages: timelineMessages,
@@ -459,7 +484,7 @@ export function ChannelScreen({
unreadCount,
} = useChannelUnreadState({
activeChannelId,
- timelineMessages,
+ timelineMessages: unreadTimelineMessages,
currentPubkey,
openThreadHeadId,
threadReplyTargetId,
@@ -476,6 +501,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,
@@ -493,6 +549,7 @@ export function ChannelScreen({
deleteMessageMutation,
editMessageMutation,
editTargetId,
+ messageAutoRouteAgentPubkeys,
expandedThreadReplyIds,
getFirstReplyIdForMessage,
getReplyDescendantIdsForMessage,
@@ -505,6 +562,7 @@ export function ChannelScreen({
setOpenThreadHeadId,
setThreadReplyTargetId,
setThreadScrollTargetId,
+ threadAutoRouteAgentPubkeys,
threadReplyTargetId,
toggleReactionMutation,
});
@@ -841,6 +899,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 b20a45188..67968dd18 100644
--- a/desktop/src/features/chat/ui/ChatHeader.tsx
+++ b/desktop/src/features/chat/ui/ChatHeader.tsx
@@ -16,24 +16,32 @@ 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;
/** Render the chrome wrapper without an individual backdrop when a parent supplies shared blur. */
transparentChrome?: boolean;
+ subtitle?: string | null;
};
const HEADER_ICON_CLASS = "h-4 w-4 text-muted-foreground";
@@ -85,19 +93,29 @@ 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,
transparentChrome = false,
+ 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();
@@ -111,40 +129,69 @@ export function ChatHeader({
}
}
+ const renderedLeadingContent =
+ leadingContent === false
+ ? null
+ : (leadingContent ?? (
+
+ ));
+
const header = (
+ ) : 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.
@@ -823,7 +675,7 @@ export function MessageThreadPanel({
onCancelReply={composerReplyTarget ? onCancelReply : undefined}
onEditLastOwnMessage={onEditLastOwnMessage}
onEditSave={onEditSave}
- onSend={onSend}
+ onSend={handleSendReply}
placeholder={`Reply in thread to ${threadHead.author}`}
profiles={profiles}
replyTarget={composerReplyTarget}
@@ -837,9 +689,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 8d8756275..94f246c0a 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";
@@ -28,6 +29,7 @@ export type MessageTimelineHandle = {
};
type MessageTimelineProps = {
+ agentConversationMarkers?: readonly AgentConversationMarker[];
agentPubkeys?: ReadonlySet;
channelId?: string | null;
channelIntro?: ChannelIntro | null;
@@ -52,7 +54,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;
@@ -64,6 +69,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?: (
@@ -85,6 +94,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. */
@@ -93,6 +103,7 @@ type MessageTimelineProps = {
unreadCount?: number;
/** Per-thread unread counts keyed by thread root id. */
threadUnreadCounts?: ReadonlyMap;
+ trailingContent?: React.ReactNode;
};
type ChannelIntroAction = {
@@ -137,6 +148,7 @@ const MessageTimelineBase = React.forwardRef<
MessageTimelineProps
>(function MessageTimeline(
{
+ agentConversationMarkers,
agentPubkeys,
channelId,
channelIntro = null,
@@ -149,8 +161,11 @@ const MessageTimelineBase = React.forwardRef<
currentPubkey,
fetchOlder,
hasComposerOverlay = true,
+ contentTopPadding = "chrome",
hasOlderMessages = true,
isFetchingOlder = false,
+ layoutShiftKey = null,
+ messageListPlacement = "bottom",
followThreadById,
huddleMemberPubkeys,
huddleMemberPubkeysPending = false,
@@ -163,6 +178,7 @@ const MessageTimelineBase = React.forwardRef<
onEdit,
onMarkUnread,
onMarkRead,
+ onOpenAgentConversation,
onReply,
channelName,
channelType,
@@ -174,11 +190,13 @@ const MessageTimelineBase = React.forwardRef<
searchActiveMessageId = null,
searchMatchingMessageIds,
searchQuery,
+ showInitialDayDivider = true,
targetMessageId = null,
onTargetReached,
firstUnreadMessageId = null,
unreadCount = 0,
threadUnreadCounts,
+ trailingContent,
}: MessageTimelineProps,
ref,
) {
@@ -215,15 +233,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";
+ const scrollContainerDomKey = scrollRouteKey;
const timelineBodySurface = selectTimelineBodySurface({
deferredCount: deferredMessages.length,
@@ -246,6 +265,7 @@ const MessageTimelineBase = React.forwardRef<
isLoading: showTimelineSkeleton,
messages: deferredMessages,
onTargetReached,
+ resetKey: scrollRouteKey,
scrollContainerRef,
targetMessageId,
});
@@ -404,7 +424,7 @@ const MessageTimelineBase = React.forwardRef<
{showTimelineSkeleton ? (
@@ -570,10 +596,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 22ebe8cb4..baf0dc0e3 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -6,6 +6,7 @@ import {
getTimelineItemKey,
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 {
@@ -18,6 +19,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
+import { AgentConversationMarkerRow } from "./AgentConversationMarkerRow";
import { DayDivider } from "./DayDivider";
import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
@@ -25,6 +27,7 @@ import { SystemMessageRow } from "./SystemMessageRow";
import { UnreadDivider } from "./UnreadDivider";
type TimelineMessageListProps = {
+ agentConversationMarkers?: readonly AgentConversationMarker[];
agentPubkeys?: ReadonlySet;
channelId?: string | null;
channelName?: string;
@@ -47,6 +50,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?: (
@@ -71,11 +78,13 @@ 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;
};
export const TimelineMessageList = React.memo(function TimelineMessageList({
+ agentConversationMarkers,
agentPubkeys,
channelId,
channelName,
@@ -95,6 +104,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
onEdit,
onMarkUnread,
onMarkRead,
+ onOpenAgentConversation,
onReply,
isSendingVideoReviewComment = false,
onSendVideoReviewComment,
@@ -103,6 +113,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
searchActiveMessageId = null,
searchMatchingMessageIds,
searchQuery,
+ showInitialDayDivider = true,
threadUnreadCounts,
unfollowThreadById,
}: TimelineMessageListProps) {
@@ -159,8 +170,21 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
// The flattened item stream, memoized on the entries and the unread boundary
// (the unread divider is its own item, so it shifts subsequent rows).
const itemsResult = React.useMemo(
- () => buildTimelineItems(entries, firstUnreadMessageId),
- [entries, firstUnreadMessageId],
+ () =>
+ buildTimelineItems(entries, firstUnreadMessageId, {
+ showInitialDayDivider,
+ }),
+ [entries, firstUnreadMessageId, showInitialDayDivider],
+ );
+ const agentConversationMarkerByMessageId = React.useMemo(
+ () =>
+ new Map(
+ (agentConversationMarkers ?? []).map((marker) => [
+ marker.agentReplyId,
+ marker,
+ ]),
+ ),
+ [agentConversationMarkers],
);
const renderItem = React.useCallback(
@@ -186,6 +210,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
return (
& {
+ agentConversationMarker?: AgentConversationMarker;
entry: MainTimelineEntry;
footer: React.ReactNode;
isUnread?: boolean;
@@ -310,6 +342,7 @@ type MessageRowItemProps = Pick<
function MessageRowItem({
agentPubkeys,
+ agentConversationMarker,
channelId,
currentPubkey,
entry,
@@ -324,6 +357,7 @@ function MessageRowItem({
onEdit,
onMarkUnread,
onMarkRead,
+ onOpenAgentConversation,
onReply,
onToggleReaction,
profiles,
@@ -382,6 +416,7 @@ function MessageRowItem({
}
onMarkRead={onMarkRead}
onMarkUnread={onMarkUnread}
+ onOpenAgentConversation={onOpenAgentConversation}
onToggleReaction={onToggleReaction}
onReply={onReply}
onUnfollowThread={
@@ -401,6 +436,16 @@ function MessageRowItem({
summary={summary}
unreadCount={threadUnreadCounts?.get(message.id)}
/>
+ {agentConversationMarker ? (
+
+ ) : null}
{footer}
);
@@ -423,6 +468,7 @@ function MessageRowItem({
onEdit={canEdit}
onMarkRead={onMarkRead}
onMarkUnread={onMarkUnread}
+ onOpenAgentConversation={onOpenAgentConversation}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
@@ -430,6 +476,15 @@ function MessageRowItem({
showDepthGuides={false}
videoReviewContext={videoReviewContext}
/>
+ {agentConversationMarker ? (
+
+ ) : null}
{footer}
);
diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts
index 6a18a9684..6d8e89ecc 100644
--- a/desktop/src/features/messages/ui/useAnchoredScroll.ts
+++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts
@@ -40,6 +40,8 @@ type UseAnchoredScrollOptions = {
contentRef: React.RefObject;
/** Resets when changed; lets us drop anchor + scroll state across channels. */
channelId?: string | null;
+ /** Resets when changed; includes channel plus route-specific layout state. */
+ resetKey?: string | null;
/** Suppresses initial scroll-to-bottom while a skeleton is showing. */
isLoading: boolean;
/** Source of truth for the rendered list. Used to detect new-at-bottom
@@ -145,6 +147,7 @@ export function useAnchoredScroll({
scrollContainerRef,
contentRef,
channelId,
+ resetKey = channelId ?? null,
isLoading,
messages,
@@ -181,10 +184,10 @@ export function useAnchoredScroll({
// guard runs on a native scroll event, outside React's render cycle.
const settlingRef = React.useRef(false);
- // Reset everything when the channel changes β the layout effect that runs
- // immediately after this reset is responsible for either jumping to bottom
- // or to the target message for the new channel.
- // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger β we want this effect to fire exactly when the channel changes (and on mount).
+ // Reset everything when the route's scroll identity changes β the layout
+ // effect that runs immediately after this reset is responsible for either
+ // jumping to bottom or to the target message for the new view.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: resetKey is intentionally the sole trigger β it includes channel identity plus route-specific layout state.
React.useLayoutEffect(() => {
anchorRef.current = { kind: "at-bottom" };
setIsAtBottom(true);
@@ -205,7 +208,7 @@ export function useAnchoredScroll({
cancelAnimationFrame(mountPinRafIdRef.current);
mountPinRafIdRef.current = null;
}
- }, [channelId]);
+ }, [resetKey]);
const scrollToBottomImperative = React.useCallback(
(behavior: ScrollBehavior = "auto") => {
@@ -454,7 +457,7 @@ export function useAnchoredScroll({
// mid-history, native scroll anchoring (overflow-anchor) holds the reading
// row across the reflow, so there's nothing to do.
// ---------------------------------------------------------------------------
- // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is a deliberate re-subscription trigger β the effect body reads only the stable refs, but on a channel switch the keyed scroll container remounts and contentRef.current becomes a fresh node, so the observer must disconnect from the previous channel's detached node and re-observe the live one.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: resetKey is a deliberate re-subscription trigger β the effect body reads only the stable refs, but on route identity changes the keyed scroll container remounts and contentRef.current becomes a fresh node, so the observer must disconnect from the previous route's detached node and re-observe the live one.
React.useEffect(() => {
const content = contentRef.current;
if (!content || typeof ResizeObserver === "undefined") return;
@@ -467,7 +470,7 @@ export function useAnchoredScroll({
});
observer.observe(content);
return () => observer.disconnect();
- }, [channelId, contentRef, scrollContainerRef]);
+ }, [resetKey, contentRef, scrollContainerRef]);
// ---------------------------------------------------------------------------
// Target message handling (deep link, jump-to-reply, etc.). Distinct from
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 46293a3d4..cd820c8ff 100644
--- a/desktop/src/features/sidebar/ui/AppSidebar.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx
@@ -6,6 +6,7 @@ import { FeatureGate } from "@/shared/features";
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
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 {
@@ -75,6 +76,7 @@ type CreateChannelKind = "stream" | "forum";
type AppSidebarProps = {
activeWorkspace: Workspace | null;
+ agentConversations?: AgentConversation[];
channels: Channel[];
currentPubkey?: string;
fallbackDisplayName?: string;
@@ -87,6 +89,7 @@ type AppSidebarProps = {
profile?: Profile;
selfPresenceStatus: PresenceStatus;
errorMessage?: string;
+ selectedAgentConversationId?: string | null;
selectedChannelId: string | null;
selectedView:
| "home"
@@ -115,6 +118,7 @@ type AppSidebarProps = {
templateId?: string;
}) => Promise;
onOpenAddWorkspace: () => void;
+ onHideAgentConversation?: (conversationId: string) => void;
onHideDm: (channelId: string) => void;
onMarkChannelUnread: (channelId: string) => void;
onMarkChannelRead: (
@@ -130,6 +134,7 @@ type AppSidebarProps = {
) => void;
onRemoveWorkspace: (id: string) => void;
onCreateAgent: () => void;
+ onSelectAgentConversation?: (conversationId: string) => void;
onSelectAgents: () => void;
onSelectProjects: () => void;
onSelectPulse: () => void;
@@ -165,6 +170,7 @@ type AppSidebarProps = {
export function AppSidebar({
activeWorkspace,
+ agentConversations = [],
channels,
currentPubkey,
fallbackDisplayName,
@@ -177,6 +183,7 @@ export function AppSidebar({
profile,
selfPresenceStatus,
errorMessage,
+ selectedAgentConversationId,
selectedChannelId,
selectedView,
unreadChannelCounts,
@@ -187,6 +194,7 @@ export function AppSidebar({
onCreateChannel,
onCreateForum,
onOpenAddWorkspace,
+ onHideAgentConversation,
onHideDm,
onMarkChannelUnread,
onMarkChannelRead,
@@ -196,6 +204,7 @@ export function AppSidebar({
onUpdateWorkspace,
onRemoveWorkspace,
onCreateAgent,
+ onSelectAgentConversation,
onSelectAgents,
onSelectProjects,
onSelectPulse,
@@ -461,6 +470,21 @@ 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 displayUnreadChannelIds = unreadChannelIds;
+ const displayUnreadChannelCounts = unreadChannelCounts;
const sidebarLoadingShape = useSidebarLoadingShape({
activeWorkspaceId: activeWorkspace?.id,
currentPubkey,
@@ -478,7 +502,10 @@ export function AppSidebar({
scrollToNextBelow,
unreadAboveCount,
unreadBelowCount,
- } = useUnreadOverflow({ scrollRef, unreadChannelIds });
+ } = useUnreadOverflow({
+ scrollRef,
+ unreadChannelIds: displayUnreadChannelIds,
+ });
const isCreatingAny =
createDialogKind === "stream"
@@ -569,10 +596,14 @@ export function AppSidebar({
<>
{starredChannels.length > 0 ? (
- unreadChannelIds.has(c.id),
+ displayUnreadChannelIds.has(c.id),
)}
+ isAgentConversationActive={isAgentConversationActive}
isCollapsed={collapsedGroups.starred}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
@@ -583,14 +614,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}
@@ -610,20 +644,25 @@ 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}
@@ -631,7 +670,9 @@ export function AppSidebar({
onToggleCollapsed={() =>
toggleCollapsedSection(section.id)
}
+ onHideAgentConversation={onHideAgentConversation}
onSelectChannel={onSelectChannel}
+ onSelectAgentConversation={onSelectAgentConversation}
onMarkChannelRead={onMarkChannelRead}
onMarkChannelUnread={onMarkChannelUnread}
onMarkSectionRead={() => {
@@ -658,10 +699,14 @@ export function AppSidebar({
/>
))}
0}
+ hasUnread={displayUnreadChannelIds.size > 0}
+ isAgentConversationActive={isAgentConversationActive}
isCollapsed={collapsedGroups.channels}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
@@ -670,14 +715,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}
@@ -694,8 +742,12 @@ export function AppSidebar({
0}
+ hasUnread={displayUnreadChannelIds.size > 0}
+ isAgentConversationActive={isAgentConversationActive}
isCollapsed={collapsedGroups.forums}
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
@@ -703,14 +755,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}
@@ -734,25 +789,30 @@ 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 d435553b4..7fa5d0474 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,11 +21,13 @@ 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 {
DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY,
ProfileAvatarWithStatus,
scaleProfileAvatarStatusGeometry,
} from "@/features/profile/ui/ProfileAvatarWithStatus";
+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";
@@ -317,21 +320,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,
@@ -340,13 +348,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;
@@ -357,7 +371,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;
@@ -409,74 +425,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.test.mjs b/desktop/src/shared/api/relayChannelFilters.test.mjs
index 3519c8214..943df50d4 100644
--- a/desktop/src/shared/api/relayChannelFilters.test.mjs
+++ b/desktop/src/shared/api/relayChannelFilters.test.mjs
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
+ buildChannelAgentConversationMarkerFilter,
buildChannelAuxDeletionFilter,
buildChannelAuxFilter,
buildChannelReactionAuxFilter,
@@ -43,3 +44,10 @@ test("buildChannelStructuralAuxFilter excludes reactions", () => {
assert.deepEqual(filter["#e"], IDS);
assert.equal("#h" in filter, false);
});
+
+test("buildChannelAgentConversationMarkerFilter scopes task markers by channel and references", () => {
+ const filter = buildChannelAgentConversationMarkerFilter(CHANNEL, IDS);
+ assert.deepEqual(filter.kinds, [40004, 40010]);
+ assert.deepEqual(filter["#h"], [CHANNEL]);
+ assert.deepEqual(filter["#e"], IDS);
+});
diff --git a/desktop/src/shared/api/relayChannelFilters.ts b/desktop/src/shared/api/relayChannelFilters.ts
index d0c7e7938..6916add0c 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,
};
@@ -96,6 +97,18 @@ export function buildChannelStructuralAuxFilter(
]);
}
+export function buildChannelAgentConversationMarkerFilter(
+ channelId: string,
+ referencedEventIds: string[],
+): RelaySubscriptionFilter {
+ return {
+ kinds: [...CHANNEL_TIMELINE_STATE_KINDS],
+ "#h": [channelId],
+ "#e": referencedEventIds,
+ limit: MAX_HISTORICAL_LIMIT,
+ };
+}
+
/**
* Reactions-only filter for the message rows the GUI is currently rendering.
* Keep this separate from structural aux backfill so the slow kind:5 deletion
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index 805151e04..3d93b7a79 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -717,6 +717,7 @@ export async function sendChannelMessage(
kind?: number,
emojiTags?: string[][],
mentionTags?: string[][],
+ clientTags?: string[][],
): Promise {
const response = await invokeTauri(
"send_channel_message",
@@ -727,6 +728,7 @@ export async function sendChannelMessage(
mediaTags: mediaTags ?? null,
emojiTags: emojiTags ?? null,
mentionTags: mentionTags ?? null,
+ clientTags: clientTags ?? null,
mentionPubkeys: mentionPubkeys ?? null,
kind: kind ?? null,
},
diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts
index c5ea0a899..76129205f 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;
@@ -71,7 +74,9 @@ 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.)
KIND_HUDDLE_STARTED, // 48100 β visible huddle session card
KIND_HUDDLE_PARTICIPANT_JOINED, // 48101 β huddle lifecycle overlay
@@ -79,6 +84,13 @@ export const CHANNEL_EVENT_KINDS = [
KIND_HUDDLE_ENDED, // 48103 β huddle lifecycle overlay
] 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/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/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts
index 13c3aa6bf..4e01d04dc 100644
--- a/desktop/tests/e2e/messaging.spec.ts
+++ b/desktop/tests/e2e/messaging.spec.ts
@@ -676,14 +676,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(
@@ -702,18 +698,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 586ca3dd1..61fdb62be 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("auxiliary-panel-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("auxiliary-panel-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("auxiliary-panel-close").click();
diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart
index b659f9a0f..37cd237d8 100644
--- a/mobile/lib/features/channels/channel_messages_provider.dart
+++ b/mobile/lib/features/channels/channel_messages_provider.dart
@@ -152,8 +152,7 @@ class ChannelMessagesNotifier extends Notifier>> {
content.contains('member_removed');
}
- /// Kinds that are metadata rather than displayable content (deletions,
- /// reactions, edits, legacy pre-migration messages).
+ /// reactions, edits, legacy pre-migration messages, task markers).
static const _metadataKinds = {
EventKind.deletion,
EventKind.reaction,
@@ -161,6 +160,8 @@ class ChannelMessagesNotifier extends Notifier>> {
EventKind.streamMessageEdit,
EventKind.huddleParticipantJoined,
EventKind.huddleParticipantLeft,
+ EventKind.agentConversationCompat,
+ EventKind.agentConversation,
};
/// Minimum displayable messages we want after the initial history load.
diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart
index fd45a2133..c2cdcebef 100644
--- a/mobile/lib/shared/relay/nostr_models.dart
+++ b/mobile/lib/shared/relay/nostr_models.dart
@@ -22,7 +22,10 @@ abstract final class EventKind {
static const dmVisibility = 30622;
static const streamMessageV2 = 40002;
static const streamMessageEdit = 40003;
+ static const streamMessagePinned = 40004;
static const streamMessageDiff = 40008;
+ static const agentConversation = 40010;
+ static const agentConversationCompat = streamMessagePinned;
static const systemMessage = 40099;
static const forumPost = 45001;
static const forumComment = 45003;
@@ -48,7 +51,9 @@ abstract final class EventKind {
...channelMessageEventKinds,
40001, // legacy pre-migration stream messages
streamMessageEdit, // 40003
+ agentConversationCompat, // 40004 β staging-compatible task marker
streamMessageDiff, // 40008
+ agentConversation, // 40010 β task marker
systemMessage, // 40099
huddleStarted, // 48100 β visible huddle session row
huddleParticipantJoined, // 48101 β huddle lifecycle metadata