diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 1954f09a5b..f807726140 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -502,6 +502,11 @@ pub fn spawn_agent_child( command.env("SPROUT_ACP_AGENTS", record.parallelism.to_string()); command.env("SPROUT_ACP_MULTIPLE_EVENT_HANDLING", "owner-interrupt"); command.env("SPROUT_ACP_DEDUP", "queue"); + // Desktop-managed channel agents should answer explicit @mentions from any + // channel member, not just the human who created the local process. The ACP + // harness still requires mention matching by default, so this does not make + // agents react to ordinary channel chatter. + command.env("SPROUT_ACP_RESPOND_TO", "anyone"); command.env( "GOOSE_MODE", std::env::var("GOOSE_MODE").unwrap_or_else(|_| "auto".to_string()), diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index d4ec4bc898..5586fd7cd5 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -327,10 +327,38 @@ where return Err(relay_error_message(response).await); } - response - .json::() + let status = response.status(); + let url = response.url().clone(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("unknown") + .to_string(); + let body = response + .bytes() .await - .map_err(|error| format!("parse failed: {error}")) + .map_err(|error| format!("read response body failed: {error}"))?; + + serde_json::from_slice::(&body).map_err(|error| { + let snippet = String::from_utf8_lossy(&body) + .chars() + .take(240) + .collect::() + .replace(['\n', '\r', '\t'], " ") + .trim() + .to_string(); + + if content_type.contains("text/html") && snippet.contains("Cloudflare Access") { + return format!( + "relay returned Cloudflare Access sign-in instead of JSON for {url}. Check the active workspace token or Cloudflare/WARP access." + ); + } + + format!( + "parse failed: {error}; relay returned {status} {content_type} for {url}: {snippet}" + ) + }) } pub async fn send_empty_request(request: reqwest::RequestBuilder) -> Result<(), String> { diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index ad21aaff63..9f488540f5 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { Bot, Loader2 } from "lucide-react"; +import { Loader2 } from "lucide-react"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; @@ -9,6 +9,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { UserAvatar } from "@/shared/ui/UserAvatar"; const AGENT_LIST_HEIGHT_ESTIMATE_PX = 220; +const MAX_INLINE_AGENT_AVATARS = 3; type BotActivityBarProps = { agents: ManagedAgent[]; @@ -96,8 +97,9 @@ export function BotActivityBar({ : null; const label = typingAgents.length === 1 - ? "1 active agent" - : `${typingAgents.length} active agents`; + ? typingAgents[0]?.name + : `${typingAgents[0]?.name ?? "Agent"} +${typingAgents.length - 1}`; + const visibleInlineAgents = typingAgents.slice(0, MAX_INLINE_AGENT_AVATARS); return (
@@ -118,7 +120,24 @@ export function BotActivityBar({ onPointerLeave={scheduleCloseAgentList} type="button" > - +
+ {visibleInlineAgents.map((agent, index) => ( +
0 && "-ml-1.5")} + key={agent.pubkey} + style={{ zIndex: visibleInlineAgents.length - index }} + > + +
+ ))} +
{label} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 75da235df1..e5e9a491a5 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -6,7 +6,7 @@ import { } from "@/features/agents/hooks"; import { useChannelMembersQuery } from "@/features/channels/hooks"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; -import type { ChannelMember } from "@/shared/api/types"; +import type { ChannelMember, UserProfileSummary } from "@/shared/api/types"; import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; import { hasMention } from "./hasMention"; @@ -16,6 +16,7 @@ const MENTION_DEBOUNCE_MS = 120; export function useMentions( channelId: string | null, externalMembers?: ChannelMember[], + profiles?: Record, ) { const [mentionQuery, setMentionQuery] = React.useState(null); const [mentionStartIndex, setMentionStartIndex] = React.useState(0); @@ -49,15 +50,35 @@ export function useMentions( } return lookup; }, [managedAgentsQuery.data, personasQuery.data]); + const profileNamesByPubkey = React.useMemo(() => { + const lookup = new Map(); + for (const [pubkey, profile] of Object.entries(profiles ?? {})) { + const name = profile.displayName?.trim() || profile.nip05Handle?.trim(); + if (name) { + lookup.set(pubkey.toLowerCase(), name); + } + } + return lookup; + }, [profiles]); + + const resolveMentionName = React.useCallback( + (member: ChannelMember) => { + const pubkeyLower = member.pubkey.toLowerCase(); + return ( + member.displayName ?? + managedAgentNamesByPubkey.get(pubkeyLower) ?? + profileNamesByPubkey.get(pubkeyLower) + ); + }, + [managedAgentNamesByPubkey, profileNamesByPubkey], + ); const knownNames = React.useMemo(() => { if (!members) return []; const names: string[] = []; const seen = new Set(); for (const member of members) { - const name = - member.displayName ?? - managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()); + const name = resolveMentionName(member); if (name) { names.push(name); seen.add(name.toLowerCase()); @@ -70,7 +91,7 @@ export function useMentions( } } return names; - }, [members, managedAgentNamesByPubkey, personaNameByPubkey]); + }, [members, personaNameByPubkey, resolveMentionName]); /** Lower-cased version of knownNames, used for case-insensitive prefix matching. */ const knownNamesLower = React.useMemo( @@ -123,8 +144,7 @@ export function useMentions( .map((member) => { const pubkeyLower = member.pubkey.toLowerCase(); - const actualName = - member.displayName ?? managedAgentNamesByPubkey.get(pubkeyLower); + const actualName = resolveMentionName(member); const personaName = personaNameByPubkey.get(pubkeyLower) ?? null; const label = actualName ?? member.pubkey.slice(0, 8); @@ -155,7 +175,7 @@ export function useMentions( role: member.role === "admin" ? "admin" : null, personaName, })); - }, [managedAgentNamesByPubkey, members, mentionQuery, personaNameByPubkey]); + }, [members, mentionQuery, personaNameByPubkey, resolveMentionName]); const isMentionOpen = mentionQuery !== null && suggestions.length > 0; @@ -236,9 +256,7 @@ export function useMentions( if (pubkeys.includes(member.pubkey)) { continue; } - const name = - member.displayName ?? - managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()); + const name = resolveMentionName(member); if (name && hasMention(text, name)) { pubkeys.push(member.pubkey); } @@ -246,7 +264,7 @@ export function useMentions( return [...new Set(pubkeys)]; }, - [members, managedAgentNamesByPubkey], + [members, resolveMentionName], ); const clearMentions = React.useCallback(() => { diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 3a5380a822..ff5fd32042 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -19,6 +19,7 @@ import { } from "@/features/messages/lib/normalizeMentionClipboard"; import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor"; import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { Button } from "@/shared/ui/button"; import { ChannelAutocomplete } from "./ChannelAutocomplete"; import { ComposerAttachments } from "./ComposerAttachments"; @@ -49,6 +50,7 @@ type MessageComposerProps = { mediaTags?: string[][], ) => Promise; placeholder?: string; + profiles?: UserProfileLookup; replyTarget?: { author: string; body: string; @@ -70,6 +72,7 @@ export function MessageComposer({ onEditSave, onSend, placeholder, + profiles, replyTarget = null, typingParentEventId = null, typingRootEventId = null, @@ -91,7 +94,7 @@ export function MessageComposer({ const effectiveDraftKey = draftKey ?? channelId; const previousDraftKeyRef = React.useRef(null); - const mentions = useMentions(channelId); + const mentions = useMentions(channelId, undefined, profiles); const channelLinks = useChannelLinks(); const emojiAutocomplete = useEmojiAutocomplete(); const notifyTyping = useTypingBroadcast( diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 3d2eb028b2..2b3afcd8d9 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -285,6 +285,7 @@ export function MessageThreadPanel({ onEditSave={onEditSave} onSend={onSend} placeholder={`Reply in thread to ${threadHead.author}`} + profiles={profiles} replyTarget={composerReplyTarget} typingParentEventId={threadHead.id} typingRootEventId={threadHead.rootId} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 6a9d48395e..8205ef0b3f 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -120,7 +120,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ renderedTrailingContent = true; elements.push(