diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index fdefe883c3..1ba809eb5e 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -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, } } @@ -1061,6 +1074,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=[{}]", @@ -1139,8 +1155,9 @@ fn turn_is_human_facing( /// 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 +/// - 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). @@ -1155,8 +1172,9 @@ fn resolve_reply_anchor( } Some( thread_tags - .root_event_id + .agent_reply_event_id .clone() + .or_else(|| thread_tags.root_event_id.clone()) .unwrap_or_else(|| triggering_event_id.to_string()), ) } @@ -2771,6 +2789,26 @@ mod tests { assert_eq!(tags.parent_event_id.as_deref(), Some("parent456")); } + #[test] + fn test_parse_thread_tags_agent_conversation_client_anchor() { + let event = make_event_with_tags( + "dedicated task follow-up", + vec![ + vec!["e".into(), "root123".into(), "".into(), "root".into()], + vec!["e".into(), "parent456".into(), "".into(), "reply".into()], + vec![ + "client".into(), + "agent-conversation".into(), + "agent789".into(), + ], + ], + ); + let tags = parse_thread_tags(&event); + assert_eq!(tags.root_event_id.as_deref(), Some("root123")); + assert_eq!(tags.parent_event_id.as_deref(), Some("parent456")); + assert_eq!(tags.agent_reply_event_id.as_deref(), Some("agent789")); + } + #[test] fn test_parse_thread_tags_with_mentions() { let event = make_event_with_tags( @@ -3106,6 +3144,7 @@ mod tests { fn thread_tags(root: Option<&str>, 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(), @@ -3741,6 +3780,46 @@ 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 prompt = format_prompt(&batch, &FormatPromptArgs::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_present_for_dm_thread_reply() { let ch = Uuid::new_v4(); diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 0fe5d6f17e..da3219e682 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -91,8 +91,8 @@ const overrides = new Map([ // 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 diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 2c8dac5a49..1b28a5f5ff 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/features/agents/ui/AgentConversationScreen.tsx b/desktop/src/features/agents/ui/AgentConversationScreen.tsx index 14300b6ac7..138c7a1d89 100644 --- a/desktop/src/features/agents/ui/AgentConversationScreen.tsx +++ b/desktop/src/features/agents/ui/AgentConversationScreen.tsx @@ -556,6 +556,9 @@ export function AgentConversationScreen({ mediaTags?: string[][], ) => { await sendMessageMutation.mutateAsync({ + clientTags: [ + ["client", "agent-conversation", conversation.agentReply.id], + ], content, mediaTags, mentionPubkeys: buildAgentConversationMentionPubkeys({ @@ -565,7 +568,12 @@ export function AgentConversationScreen({ parentEventId: replyParentEventId, }); }, - [autoRoutedAgentPubkeys, replyParentEventId, sendMessageMutation], + [ + autoRoutedAgentPubkeys, + conversation.agentReply.id, + replyParentEventId, + sendMessageMutation, + ], ); const isComposerDisabled = diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 8ddfe38e82..50c9be1318 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -126,6 +126,7 @@ export function createOptimisticMessage( mentionPubkeys: string[] = [], parentEventId: string | null = null, mediaTags: string[][] = [], + clientTags: string[][] = [], ): RelayEvent { const localKey = `optimistic-${crypto.randomUUID()}`; const tags: string[][] = []; @@ -154,6 +155,9 @@ export function createOptimisticMessage( for (const tag of mediaTags) { tags.push(tag); } + for (const tag of clientTags) { + tags.push(tag); + } return { id: localKey, @@ -341,10 +345,12 @@ export function useSendMessageMutation( mentionPubkeys?: string[]; parentEventId?: string | null; mediaTags?: string[][]; + clientTags?: string[][]; }, MessageQueryContext | undefined >({ mutationFn: async ({ + clientTags, content, mentionPubkeys, parentEventId, @@ -371,7 +377,12 @@ export function useSendMessageMutation( // Messages carrying media OR custom-emoji tags MUST go through REST so // the relay's tag validation runs. The WebSocket path emits no extra // tags, so emoji-only messages would otherwise lose their emoji tag. - if (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) { + if ( + parentEventId || + imetaTags.length > 0 || + emojiTags.length > 0 || + (clientTags?.length ?? 0) > 0 + ) { const cachedMessages = queryClient.getQueryData( channelMessagesKey(channel.id), @@ -385,6 +396,7 @@ export function useSendMessageMutation( undefined, emojiTags, mentionTags, + clientTags, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji. @@ -423,6 +435,7 @@ export function useSendMessageMutation( ...imetaTags, ...emojiTags, ...mentionTags, + ...(clientTags ?? []), ], content: content.trim(), sig: "", @@ -436,7 +449,13 @@ export function useSendMessageMutation( mentionTags, ); }, - onMutate: async ({ content, mentionPubkeys, parentEventId, mediaTags }) => { + onMutate: async ({ + clientTags, + content, + mentionPubkeys, + parentEventId, + mediaTags, + }) => { if (!channel || !identity || channel.channelType === "forum") { return undefined; } @@ -454,6 +473,7 @@ export function useSendMessageMutation( mentionPubkeys ?? [], parentEventId ?? null, mediaTags ?? [], + clientTags ?? [], ); queryClient.setQueryData( diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 04f1136fd2..b61f384a83 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -883,8 +883,7 @@ export const MessageRow = React.memo( prev.onCollapseDescendants === next.onCollapseDescendants && prev.onCollapseDescendantsHoverChange === next.onCollapseDescendantsHoverChange && - Boolean(prev.onOpenAgentConversation) === - Boolean(next.onOpenAgentConversation) && + prev.onOpenAgentConversation === next.onOpenAgentConversation && prev.profiles === next.profiles && prev.searchQuery === next.searchQuery && prev.videoReviewContext === next.videoReviewContext, diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 269d497162..94f246c0af 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -265,6 +265,7 @@ const MessageTimelineBase = React.forwardRef< isLoading: showTimelineSkeleton, messages: deferredMessages, onTargetReached, + resetKey: scrollRouteKey, scrollContainerRef, targetMessageId, }); diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 6a18a9684f..6d8e89ecce 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/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 805151e049..3d93b7a793 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, },