feat: quick-add agent popover with two entry points

Replace the direct + → full dialog flow with a lightweight Radix popover
that shows available agents sorted by state (running > configured > catalog).
One click to add. The full AddChannelBotDialog is still accessible via
'More options…' at the bottom of the popover.

Two trigger points:
- The existing + button in ChannelMembersBar now opens the popover
- A new 'Add agent' button at the bottom of the sidebar Bots section

The sidebar already had People/Bots section headers — the inline add
affordance lives at the bottom of the Bots section.

Actions:
- Running agent not in channel → useAttachManagedAgentToChannelMutation
- Persona without running agent → useCreateChannelManagedAgentMutation
  (leverages existing reuse logic from agentReuse.ts)

The popover uses useBotRecents for smart ordering and pickBotName for
instance naming. Force-new-instance stays buried in the full dialog.
This commit is contained in:
Taylor Ho
2026-05-19 22:01:54 -07:00
parent 4e060bfec2
commit 0d56b5805f
3 changed files with 493 additions and 14 deletions

View File

@@ -14,6 +14,7 @@ import type { Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import { AddChannelBotDialog } from "./AddChannelBotDialog";
import { QuickAddAgentPopover } from "./QuickAddAgentPopover";
type ChannelMembersBarProps = {
channel: Channel;
@@ -29,6 +30,7 @@ export function ChannelMembersBar({
onToggleMembers,
}: ChannelMembersBarProps) {
const [isAddBotOpen, setIsAddBotOpen] = React.useState(false);
const [isQuickAddOpen, setIsQuickAddOpen] = React.useState(false);
const { startHuddle, isStarting: isStartingHuddle } = useHuddle();
const queryClient = useQueryClient();
const membersQuery = useChannelMembersQuery(channel.id);
@@ -73,6 +75,7 @@ export function ChannelMembersBar({
previousChannelIdRef.current = channel.id;
setIsAddBotOpen(false);
setIsQuickAddOpen(false);
}, [channel.id]);
const dialogErrorMessage =
@@ -87,20 +90,24 @@ export function ChannelMembersBar({
return (
<React.Fragment>
<div className="flex items-center gap-1">
<Button
aria-label="Add agent"
className="h-7 w-7 rounded-full"
data-testid="channel-add-bot-trigger"
disabled={!canAddAgents}
onClick={() => {
setIsAddBotOpen(true);
}}
size="icon"
type="button"
variant="outline"
<QuickAddAgentPopover
channelId={channel.id}
open={isQuickAddOpen}
onOpenChange={setIsQuickAddOpen}
onMoreOptions={() => setIsAddBotOpen(true)}
>
<Plus className="h-3 w-3" />
</Button>
<Button
aria-label="Add agent"
className="h-7 w-7 rounded-full"
data-testid="channel-add-bot-trigger"
disabled={!canAddAgents}
size="icon"
type="button"
variant="outline"
>
<Plus className="h-3 w-3" />
</Button>
</QuickAddAgentPopover>
<HuddleIndicator
className="h-7 w-7"

View File

@@ -1,10 +1,17 @@
import { Plus } from "lucide-react";
import * as React from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
useAddChannelMembersMutation,
useChannelMembersQuery,
} from "@/features/channels/hooks";
import { useUpdateManagedAgentMutation } from "@/features/agents/hooks";
import {
useAcpProvidersQuery,
useBackendProvidersQuery,
useManagedAgentsQuery,
useRelayAgentsQuery,
useUpdateManagedAgentMutation,
} from "@/features/agents/hooks";
import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField";
import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers";
import {
@@ -41,6 +48,8 @@ import { MembersSidebarAgentControls } from "./MembersSidebarAgentControls";
import { ChannelMemberInviteCard } from "./ChannelMemberInviteCard";
import { MembersSidebarMemberCard } from "./MembersSidebarMemberCard";
import { useMembersSidebarActions } from "./useMembersSidebarActions";
import { AddChannelBotDialog } from "./AddChannelBotDialog";
import { QuickAddAgentPopover } from "./QuickAddAgentPopover";
type MembersSidebarProps = {
channel: Channel | null;
@@ -61,6 +70,31 @@ export function MembersSidebar({
const queryClient = useQueryClient();
const membersQuery = useChannelMembersQuery(channelId, open);
const addMembersMutation = useAddChannelMembersMutation(channelId);
const providersQuery = useAcpProvidersQuery();
const backendProvidersQuery = useBackendProvidersQuery();
const sidebarManagedAgentsQuery = useManagedAgentsQuery();
const sidebarRelayAgentsQuery = useRelayAgentsQuery();
const sidebarProviders = React.useMemo(
() =>
[...(providersQuery.data ?? [])].sort((left, right) => {
const leftPriority = left.id === "goose" ? 0 : 1;
const rightPriority = right.id === "goose" ? 0 : 1;
if (leftPriority !== rightPriority) {
return leftPriority - rightPriority;
}
return left.label.localeCompare(right.label);
}),
[providersQuery.data],
);
const sidebarDialogErrorMessage =
providersQuery.error instanceof Error
? providersQuery.error.message
: sidebarManagedAgentsQuery.error instanceof Error
? sidebarManagedAgentsQuery.error.message
: sidebarRelayAgentsQuery.error instanceof Error
? sidebarRelayAgentsQuery.error.message
: null;
const changeRoleMutation = useMutation({
mutationFn: async ({ pubkey, role }: { pubkey: string; role: string }) => {
if (!channelId) throw new Error("No channel selected.");
@@ -98,6 +132,16 @@ export function MembersSidebar({
selfMember?.role === "owner" || selfMember?.role === "admin";
const isArchived =
channel?.archivedAt !== null && channel?.archivedAt !== undefined;
const canAddAgents =
channel?.channelType !== "dm" &&
!isArchived &&
(channel?.visibility === "open" || canManageMembers);
const [isSidebarQuickAddOpen, setIsSidebarQuickAddOpen] =
React.useState(false);
const [isSidebarFullDialogOpen, setIsSidebarFullDialogOpen] =
React.useState(false);
const managedAgentByPubkey = React.useMemo(
() =>
new Map(
@@ -298,6 +342,26 @@ export function MembersSidebar({
</p>
)}
</div>
{canAddAgents ? (
<QuickAddAgentPopover
channelId={channelId}
open={isSidebarQuickAddOpen}
onOpenChange={setIsSidebarQuickAddOpen}
onMoreOptions={() => setIsSidebarFullDialogOpen(true)}
>
<Button
aria-label="Add agent to channel"
className="mt-2 h-8 w-full justify-start gap-2 text-xs text-muted-foreground"
data-testid="sidebar-add-agent-trigger"
size="sm"
type="button"
variant="ghost"
>
<Plus className="h-3.5 w-3.5" />
Add agent
</Button>
</QuickAddAgentPopover>
) : null}
</section>
{changeRoleError ? (
@@ -319,6 +383,18 @@ export function MembersSidebar({
}}
open={editRespondToAgent !== null}
/>
{canAddAgents ? (
<AddChannelBotDialog
backendProviders={backendProvidersQuery.data ?? []}
backendProvidersLoading={backendProvidersQuery.isLoading}
channelId={channelId}
onOpenChange={setIsSidebarFullDialogOpen}
open={isSidebarFullDialogOpen}
providers={sidebarProviders}
providersErrorMessage={sidebarDialogErrorMessage}
providersLoading={providersQuery.isLoading}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,396 @@
import { Check, Settings2 } from "lucide-react";
import * as React from "react";
import {
useAcpProvidersQuery,
useAttachManagedAgentToChannelMutation,
useCreateChannelManagedAgentMutation,
useManagedAgentsQuery,
usePersonasQuery,
} from "@/features/agents/hooks";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import { getActivePersonas } from "@/features/agents/lib/catalog";
import { resolvePersonaProvider } from "@/features/agents/lib/resolvePersonaProvider";
import { pickBotName } from "@/features/agents/lib/pickBotName";
import { useBotRecents } from "@/features/agents/lib/useBotRecents";
import type { AgentPersona, ManagedAgent } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { cn } from "@/shared/lib/cn";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/ui/popover";
import { Spinner } from "@/shared/ui/spinner";
// ── Types ─────────────────────────────────────────────────────────────────────
type QuickAddAgentItem = {
kind: "running-available" | "running-in-channel" | "persona";
agent?: ManagedAgent;
persona?: AgentPersona;
label: string;
avatarUrl: string | null;
};
type QuickAddAgentPopoverProps = {
channelId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onMoreOptions: () => void;
children: React.ReactNode;
};
// ── Component ─────────────────────────────────────────────────────────────────
export function QuickAddAgentPopover({
channelId,
open,
onOpenChange,
onMoreOptions,
children,
}: QuickAddAgentPopoverProps) {
const managedAgentsQuery = useManagedAgentsQuery();
const personasQuery = usePersonasQuery();
const providersQuery = useAcpProvidersQuery();
const membersQuery = useChannelMembersQuery(channelId, open);
const attachMutation = useAttachManagedAgentToChannelMutation(channelId);
const createMutation = useCreateChannelManagedAgentMutation(channelId);
const { recentIds, pushRecent } = useBotRecents();
const [pendingKey, setPendingKey] = React.useState<string | null>(null);
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const managedAgents = managedAgentsQuery.data ?? [];
const personas = React.useMemo(
() => getActivePersonas(personasQuery.data ?? []),
[personasQuery.data],
);
const providers = providersQuery.data ?? [];
const defaultProvider = providers[0] ?? null;
const members = membersQuery.data ?? [];
const channelMemberPubkeys = React.useMemo(
() => new Set(members.map((m) => normalizePubkey(m.pubkey))),
[members],
);
// Build the sorted item list
const items: QuickAddAgentItem[] = React.useMemo(() => {
const result: QuickAddAgentItem[] = [];
// Running agents not in this channel
const runningAvailable = managedAgents.filter(
(agent) =>
(agent.status === "running" || agent.status === "deployed") &&
!channelMemberPubkeys.has(normalizePubkey(agent.pubkey)),
);
// Running agents already in this channel
const runningInChannel = managedAgents.filter(
(agent) =>
(agent.status === "running" || agent.status === "deployed") &&
channelMemberPubkeys.has(normalizePubkey(agent.pubkey)),
);
// Personas that don't have a running agent yet (configured but not running)
// Exclude personas whose agent is already in the channel
const personaIdsInChannel = new Set(
managedAgents
.filter((agent) =>
channelMemberPubkeys.has(normalizePubkey(agent.pubkey)),
)
.map((agent) => agent.personaId)
.filter((id): id is string => Boolean(id)),
);
const availablePersonas = personas.filter(
(persona) =>
!personaIdsInChannel.has(persona.id) &&
!runningAvailable.some((agent) => agent.personaId === persona.id),
);
// Sort running-available by recency
const sortedRunningAvailable = [...runningAvailable].sort((a, b) => {
const aPersonaIdx = a.personaId
? recentIds.indexOf(a.personaId)
: -1;
const bPersonaIdx = b.personaId
? recentIds.indexOf(b.personaId)
: -1;
const aScore = aPersonaIdx >= 0 ? aPersonaIdx : 999;
const bScore = bPersonaIdx >= 0 ? bPersonaIdx : 999;
return aScore - bScore;
});
for (const agent of sortedRunningAvailable) {
const persona = agent.personaId
? personas.find((p) => p.id === agent.personaId)
: undefined;
result.push({
kind: "running-available",
agent,
persona,
label: agent.name,
avatarUrl: persona?.avatarUrl ?? null,
});
}
for (const agent of runningInChannel) {
const persona = agent.personaId
? personas.find((p) => p.id === agent.personaId)
: undefined;
result.push({
kind: "running-in-channel",
agent,
persona,
label: agent.name,
avatarUrl: persona?.avatarUrl ?? null,
});
}
// Sort available personas: recents first, then alphabetical
const sortedPersonas = [...availablePersonas].sort((a, b) => {
const aIdx = recentIds.indexOf(a.id);
const bIdx = recentIds.indexOf(b.id);
if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx;
if (aIdx >= 0) return -1;
if (bIdx >= 0) return 1;
return a.displayName.localeCompare(b.displayName);
});
for (const persona of sortedPersonas) {
result.push({
kind: "persona",
persona,
label: persona.displayName,
avatarUrl: persona.avatarUrl,
});
}
return result;
}, [
managedAgents,
personas,
channelMemberPubkeys,
recentIds,
]);
// Reset state when popover closes
React.useEffect(() => {
if (!open) {
setPendingKey(null);
setErrorMessage(null);
}
}, [open]);
async function handleAddRunningAgent(agent: ManagedAgent) {
const key = `agent:${agent.pubkey}`;
setPendingKey(key);
setErrorMessage(null);
try {
await attachMutation.mutateAsync({ agent, ensureRunning: true });
if (agent.personaId) pushRecent(agent.personaId);
onOpenChange(false);
} catch (err) {
setErrorMessage(
err instanceof Error ? err.message : "Failed to add agent.",
);
setPendingKey(null);
}
}
async function handleAddPersona(persona: AgentPersona) {
const key = `persona:${persona.id}`;
setPendingKey(key);
setErrorMessage(null);
const { provider } = resolvePersonaProvider(
persona.provider,
providers,
defaultProvider,
);
if (!provider) {
setErrorMessage("No agent runtime available.");
setPendingKey(null);
return;
}
// Pick a name from the persona's pool
const usedNames = new Set(managedAgents.map((a) => a.name));
const instanceName = pickBotName(persona.namePool, usedNames);
try {
await createMutation.mutateAsync({
provider,
name: instanceName,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
personaId: persona.id,
model: persona.model ?? undefined,
});
pushRecent(persona.id);
onOpenChange(false);
} catch (err) {
setErrorMessage(
err instanceof Error ? err.message : "Failed to add agent.",
);
setPendingKey(null);
}
}
function handleItemClick(item: QuickAddAgentItem) {
if (item.kind === "running-in-channel") return;
if (pendingKey) return;
if (item.kind === "running-available" && item.agent) {
void handleAddRunningAgent(item.agent);
} else if (item.kind === "persona" && item.persona) {
void handleAddPersona(item.persona);
}
}
const isLoading =
managedAgentsQuery.isLoading ||
personasQuery.isLoading ||
providersQuery.isLoading;
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
align="end"
className="w-72 p-0"
sideOffset={6}
>
<div className="flex max-h-80 flex-col">
<div className="border-b px-3 py-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Add agent
</h3>
</div>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-6">
<Spinner className="h-4 w-4 text-muted-foreground" />
</div>
) : items.length === 0 ? (
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
No agents available.
</div>
) : (
<div className="py-1">
{items.map((item) => {
const itemKey =
item.kind === "persona"
? `persona:${item.persona?.id}`
: `agent:${item.agent?.pubkey}`;
const isInChannel = item.kind === "running-in-channel";
const isItemPending = pendingKey === itemKey;
return (
<button
className={cn(
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm transition-colors",
isInChannel
? "cursor-default opacity-50"
: "cursor-pointer hover:bg-accent focus-visible:bg-accent focus-visible:outline-none",
isItemPending && "pointer-events-none opacity-60",
)}
disabled={isInChannel || Boolean(pendingKey)}
key={itemKey}
onClick={() => handleItemClick(item)}
type="button"
>
<QuickAddAgentAvatar
avatarUrl={item.avatarUrl}
label={item.label}
isRunning={item.kind !== "persona"}
/>
<span className="flex-1 truncate font-medium">
{item.label}
</span>
{isInChannel ? (
<Check className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : item.kind === "running-available" ? (
<span className="shrink-0 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
running
</span>
) : null}
{isItemPending ? (
<Spinner className="h-3.5 w-3.5 shrink-0 text-primary" />
) : null}
</button>
);
})}
</div>
)}
</div>
{errorMessage ? (
<div className="border-t px-3 py-2">
<p className="text-xs text-destructive">{errorMessage}</p>
</div>
) : null}
<div className="border-t">
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => {
onOpenChange(false);
onMoreOptions();
}}
type="button"
>
<Settings2 className="h-3.5 w-3.5" />
<span>More options…</span>
</button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
// ── Avatar helper ─────────────────────────────────────────────────────────────
function QuickAddAgentAvatar({
avatarUrl,
label,
isRunning,
}: {
avatarUrl: string | null;
label: string;
isRunning: boolean;
}) {
const initials = label
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase();
return (
<div className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border/50 bg-muted/30">
{avatarUrl ? (
<img
alt={label}
className="h-full w-full rounded-full object-cover"
referrerPolicy="no-referrer"
src={rewriteRelayUrl(avatarUrl)}
/>
) : (
<span className="text-[10px] font-semibold text-muted-foreground">
{initials}
</span>
)}
{isRunning ? (
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-background bg-emerald-500" />
) : null}
</div>
);
}