mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
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:
@@ -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()),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user