Polish multi-agent response activity

Keep active agent state aligned with replies while making the activity pill clearer with avatars and profile-aware mention resolution.

Made-with: Cursor
This commit is contained in:
Thomas Petersen
2026-04-30 10:53:44 -04:00
parent d1c44aa3bd
commit a7953ff80a
7 changed files with 95 additions and 21 deletions

View File

@@ -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()),

View File

@@ -327,10 +327,38 @@ where
return Err(relay_error_message(response).await);
}
response
.json::<T>()
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::<T>(&body).map_err(|error| {
let snippet = String::from_utf8_lossy(&body)
.chars()
.take(240)
.collect::<String>()
.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> {

View File

@@ -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 (
<div className="min-w-0" data-testid="bot-activity-bar">
@@ -118,7 +120,24 @@ export function BotActivityBar({
onPointerLeave={scheduleCloseAgentList}
type="button"
>
<Bot className="h-3 w-3 shrink-0" />
<div className="flex shrink-0 items-center">
{visibleInlineAgents.map((agent, index) => (
<div
className={cn(index > 0 && "-ml-1.5")}
key={agent.pubkey}
style={{ zIndex: visibleInlineAgents.length - index }}
>
<UserAvatar
avatarUrl={
profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null
}
className="rounded-full border border-background shadow-none"
displayName={agent.name}
size="xs"
/>
</div>
))}
</div>
<span className="min-w-0 truncate">{label}</span>
<Loader2 className="h-3 w-3 shrink-0 animate-spin opacity-60" />
</button>

View File

@@ -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<string, UserProfileSummary>,
) {
const [mentionQuery, setMentionQuery] = React.useState<string | null>(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<string, string>();
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<string[]>(() => {
if (!members) return [];
const names: string[] = [];
const seen = new Set<string>();
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<string[]>(
@@ -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(() => {

View File

@@ -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<void>;
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<string | null>(null);
const mentions = useMentions(channelId);
const mentions = useMentions(channelId, undefined, profiles);
const channelLinks = useChannelLinks();
const emojiAutocomplete = useEmojiAutocomplete();
const notifyTyping = useTypingBroadcast(

View File

@@ -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}

View File

@@ -120,7 +120,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
renderedTrailingContent = true;
elements.push(
<div
className="flex min-w-0 items-center gap-3"
className="flex min-w-0 items-center gap-1.5"
data-testid="message-thread-summary-with-footer"
key={`thread-summary-with-footer-${message.id}`}
>