From 2e09796e9cfc2f15972bb1feac456c3f45465cbb Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 21:08:28 -0700 Subject: [PATCH] Add Select button with animated team chips to popover header - 'Select' button in the header reveals team filter chips on click - Title fades out, team chips animate in from right using motion/react - Clicking a team chip auto-selects all its member agents - Multi-select mode shows checkboxes + 'Add (N)' confirm button - Single-click fast path preserved when not in select mode - Team chips scroll horizontally when overflowing - Exiting: deselect all returns to default title state --- .../channels/ui/QuickAddAgentPopover.tsx | 236 +++++++++++++++++- 1 file changed, 225 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index fd8855cd57..c43e11a7a3 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -1,22 +1,30 @@ import { Check, Settings2 } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; import * as React from "react"; import { useAcpProvidersQuery, useAttachManagedAgentToChannelMutation, useCreateChannelManagedAgentMutation, + useCreateChannelManagedAgentsMutation, useManagedAgentsQuery, usePersonasQuery, + useTeamsQuery, } 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 { + getUsableTeams, + resolveTeamPersonas, +} from "@/features/agents/lib/teamPersonas"; +import type { AgentPersona, AgentTeam, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Spinner } from "@/shared/ui/spinner"; @@ -89,16 +97,22 @@ export function QuickAddAgentPopover({ const managedAgentsQuery = useManagedAgentsQuery(); const personasQuery = usePersonasQuery(); const providersQuery = useAcpProvidersQuery(); + const teamsQuery = useTeamsQuery(); const membersQuery = useChannelMembersQuery( channelId, open && channelId !== null, ); const attachMutation = useAttachManagedAgentToChannelMutation(channelId); const createMutation = useCreateChannelManagedAgentMutation(channelId); + const batchCreateMutation = useCreateChannelManagedAgentsMutation(channelId); const { recentIds, pushRecent } = useBotRecents(); const [pendingKey, setPendingKey] = React.useState(null); const [errorMessage, setErrorMessage] = React.useState(null); + const [selectMode, setSelectMode] = React.useState(false); + const [selectedKeys, setSelectedKeys] = React.useState>( + new Set(), + ); const managedAgents = managedAgentsQuery.data ?? []; const personas = React.useMemo( @@ -108,12 +122,18 @@ export function QuickAddAgentPopover({ const providers = providersQuery.data ?? []; const defaultProvider = providers[0] ?? null; const members = membersQuery.data ?? []; + const teams = teamsQuery.data ?? []; const channelMemberPubkeys = React.useMemo( () => new Set(members.map((m) => normalizePubkey(m.pubkey))), [members], ); + const usableTeams = React.useMemo( + () => getUsableTeams(teams, personas), + [teams, personas], + ); + // Build the sorted item list const items: QuickAddAgentItem[] = React.useMemo(() => { const result: QuickAddAgentItem[] = []; @@ -205,9 +225,13 @@ export function QuickAddAgentPopover({ if (!open) { setPendingKey(null); setErrorMessage(null); + setSelectMode(false); + setSelectedKeys(new Set()); } }, [open]); + // ── Single-add handlers ─────────────────────────────────────────────────── + async function handleAddRunningAgent(agent: ManagedAgent) { if (!channelId) return; const key = `agent:${agent.pubkey}`; @@ -266,15 +290,130 @@ export function QuickAddAgentPopover({ } } + // ── Multi-select handlers ───────────────────────────────────────────────── + + function toggleSelection(key: string) { + setSelectedKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + if (next.size === 0) { + setSelectMode(false); + } + return next; + }); + } + + function handleTeamChipClick(team: AgentTeam) { + const resolution = resolveTeamPersonas(team, personas); + setSelectedKeys((prev) => { + const next = new Set(prev); + for (const persona of resolution.resolvedPersonas) { + // Find matching item (running agent or persona) + const runningItem = items.find( + (i) => + i.kind === "running-available" && i.agent.personaId === persona.id, + ); + if (runningItem) { + next.add(getItemKey(runningItem)); + } else { + const personaItem = items.find( + (i) => i.kind === "persona" && i.persona.id === persona.id, + ); + if (personaItem) { + next.add(getItemKey(personaItem)); + } + } + } + return next; + }); + } + + async function handleBatchAdd() { + if (!channelId || selectedKeys.size === 0) return; + setPendingKey("batch"); + setErrorMessage(null); + + const usedNames = new Set(managedAgents.map((a) => a.name)); + const toAttach: ManagedAgent[] = []; + const toCreate: Array<{ persona: AgentPersona; instanceName: string }> = []; + + for (const key of selectedKeys) { + const item = items.find((i) => getItemKey(i) === key); + if (!item || item.kind === "running-in-channel") continue; + + if (item.kind === "running-available") { + toAttach.push(item.agent); + } else { + const instanceName = safeBotName(item.persona, usedNames); + usedNames.add(instanceName); + toCreate.push({ persona: item.persona, instanceName }); + } + } + + try { + for (const agent of toAttach) { + await attachMutation.mutateAsync({ agent, ensureRunning: true }); + if (agent.personaId) pushRecent(agent.personaId); + } + + if (toCreate.length > 0 && defaultProvider) { + const inputs = toCreate.map(({ persona, instanceName }) => { + const { provider } = resolvePersonaProvider( + persona.provider, + providers, + defaultProvider, + ); + const providerToUse = provider ?? defaultProvider; + return { + provider: { + id: providerToUse.id, + label: providerToUse.label, + command: providerToUse.command, + defaultArgs: providerToUse.defaultArgs, + mcpCommand: providerToUse.mcpCommand, + }, + name: instanceName, + systemPrompt: persona.systemPrompt, + avatarUrl: persona.avatarUrl ?? undefined, + personaId: persona.id, + model: persona.model ?? undefined, + }; + }); + + await batchCreateMutation.mutateAsync(inputs); + for (const { persona } of toCreate) { + pushRecent(persona.id); + } + } + + onOpenChange(false); + } catch (err) { + setErrorMessage( + err instanceof Error ? err.message : "Failed to add agents.", + ); + setPendingKey(null); + } + } + + // ── Item click dispatcher ───────────────────────────────────────────────── + function handleItemClick(item: QuickAddAgentItem) { if (item.kind === "running-in-channel") return; if (pendingKey) return; if (!channelId) return; - if (item.kind === "running-available") { - void handleAddRunningAgent(item.agent); + if (selectMode) { + toggleSelection(getItemKey(item)); } else { - void handleAddPersona(item.persona); + if (item.kind === "running-available") { + void handleAddRunningAgent(item.agent); + } else { + void handleAddPersona(item.persona); + } } } @@ -283,6 +422,8 @@ export function QuickAddAgentPopover({ personasQuery.isLoading || providersQuery.isLoading; + const multiSelectActive = selectMode && selectedKeys.size > 0; + if (!channelId) { return <>{children}; } @@ -322,10 +463,68 @@ export function QuickAddAgentPopover({ } }} > -
-

- Add agent -

+ {/* Header with animated title / team chips */} +
+ + {selectMode ? ( + + {usableTeams.map((team) => ( + + ))} + + ) : ( + + Add agent + + )} + + + {/* Right side: Select toggle or Add (N) button */} +
+ {multiSelectActive ? ( + + ) : usableTeams.length > 0 ? ( + + ) : null} +
{/* Scrollable content — clips mid-item to hint at more */} @@ -347,17 +546,20 @@ export function QuickAddAgentPopover({ {items.map((item) => { const itemKey = getItemKey(item); const isInChannel = item.kind === "running-in-channel"; - const isItemPending = pendingKey === itemKey; + const isItemPending = + pendingKey === itemKey || pendingKey === "batch"; + const isSelected = selectedKeys.has(itemKey); return (