From d8771fe720fbd14e82d5a5bb4e2ea90857ea462f Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 09:22:29 -0700 Subject: [PATCH] feat: hybrid multi-select with team support in QuickAddAgentPopover Transform the Quick Add Agent popover into a hybrid interaction model: - Single-click on an individual agent still adds immediately (fast path) - Clicking a team row selects all its personas and enters multi-select mode - In multi-select mode, clicking agents toggles checkbox selection - Sticky 'Add (N)' confirm button appears at the bottom in multi-select - Teams section at the top shows usable teams with member count - Scroll container clips mid-item (max-h-[13.75rem]) to hint at more content - Nothing pre-selected; running agents still sort to top via recency - Batch add uses useCreateChannelManagedAgentsMutation for new personas and individual attach for already-running agents --- .../channels/ui/QuickAddAgentPopover.tsx | 355 ++++++++++++++---- 1 file changed, 284 insertions(+), 71 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 5cda817d70..b4ad37ae3d 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -1,19 +1,25 @@ -import { Check, Settings2 } from "lucide-react"; +import { Check, Plus, Settings2, Users } from "lucide-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"; @@ -73,8 +79,6 @@ function getItemKey(item: QuickAddAgentItem): string { function safeBotName(persona: AgentPersona, usedNames: Set): string { const pool = persona.namePool ?? []; const name = pickBotName(pool, usedNames); - // pickBotName always returns a string from the universal pool fallback, - // but guard defensively in case of edge cases. if (name && name.trim().length > 0) return name; return persona.displayName || "Agent"; } @@ -88,22 +92,25 @@ export function QuickAddAgentPopover({ onMoreOptions, children, }: QuickAddAgentPopoverProps) { - // Gate channel members query to only fire when the popover is open. - // Managed agents, personas, and providers are globally cached by - // ChannelMembersBar (always mounted), so no extra fetch cost here. 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 [multiSelectMode, setMultiSelectMode] = React.useState(false); + const [selectedKeys, setSelectedKeys] = React.useState>( + new Set(), + ); const managedAgents = managedAgentsQuery.data ?? []; const personas = React.useMemo( @@ -113,31 +120,35 @@ 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], ); + // Usable teams (all personas resolved) + const usableTeams = React.useMemo( + () => getUsableTeams(teams, personas), + [teams, personas], + ); + // 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 whose agent is already in the channel (exclude from "available" list) const personaIdsInChannel = new Set( managedAgents .filter((agent) => @@ -153,7 +164,6 @@ export function QuickAddAgentPopover({ !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; @@ -188,7 +198,6 @@ export function QuickAddAgentPopover({ }); } - // 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); @@ -215,9 +224,13 @@ export function QuickAddAgentPopover({ if (!open) { setPendingKey(null); setErrorMessage(null); + setMultiSelectMode(false); + setSelectedKeys(new Set()); } }, [open]); + // ── Single-add handlers (preserved fast path) ───────────────────────────── + async function handleAddRunningAgent(agent: ManagedAgent) { if (!channelId) return; const key = `agent:${agent.pubkey}`; @@ -276,15 +289,144 @@ 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); + } + // Exit multi-select mode if nothing selected + if (next.size === 0) { + setMultiSelectMode(false); + } + return next; + }); + } + + function handleTeamClick(team: AgentTeam) { + const resolution = resolveTeamPersonas(team, personas); + const personaKeys = resolution.resolvedPersonas.map( + (p) => `persona:${p.id}`, + ); + + // Enter multi-select mode and select all team personas + setMultiSelectMode(true); + setSelectedKeys((prev) => { + const next = new Set(prev); + for (const key of personaKeys) { + // Only select if the item is actually in our addable list + const item = items.find((i) => getItemKey(i) === key); + if (item && item.kind !== "running-in-channel") { + next.add(key); + } + } + // Also select running agents that belong to team personas + for (const persona of resolution.resolvedPersonas) { + const runningItem = items.find( + (i) => + i.kind === "running-available" && i.agent.personaId === persona.id, + ); + if (runningItem) { + next.add(getItemKey(runningItem)); + } + } + 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 { + // Attach running agents individually + for (const agent of toAttach) { + await attachMutation.mutateAsync({ agent, ensureRunning: true }); + if (agent.personaId) pushRecent(agent.personaId); + } + + // Batch-create new agents + 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 (multiSelectMode) { + // In multi-select mode, toggle selection + toggleSelection(getItemKey(item)); } else { - void handleAddPersona(item.persona); + // Single-click fast path — add immediately + if (item.kind === "running-available") { + void handleAddRunningAgent(item.agent); + } else { + void handleAddPersona(item.persona); + } } } @@ -293,7 +435,6 @@ export function QuickAddAgentPopover({ personasQuery.isLoading || providersQuery.isLoading; - // Don't render the popover content when there's no channel if (!channelId) { return <>{children}; } @@ -307,7 +448,7 @@ export function QuickAddAgentPopover({ sideOffset={6} >
{ const container = e.currentTarget; @@ -339,66 +480,118 @@ export function QuickAddAgentPopover({
-
+ {/* Scrollable content area — max-height clips mid-item to hint at more */} +
{isLoading ? (
- ) : items.length === 0 ? ( -
- No agents available. -
) : ( -
- {items.map((item) => { - const itemKey = getItemKey(item); - const isInChannel = item.kind === "running-in-channel"; - const isItemPending = pendingKey === itemKey; + <> + {/* Teams section */} + {usableTeams.length > 0 ? ( +
+ {usableTeams.map((team) => { + const resolution = resolveTeamPersonas(team, personas); + return ( + + ); + })} +
+ ) : null} - return ( - - ); - })} -
+ {/* Agents section */} + {items.length === 0 ? ( +
+ No agents available. +
+ ) : ( +
+ {items.map((item) => { + const itemKey = getItemKey(item); + const isInChannel = item.kind === "running-in-channel"; + const isItemPending = + pendingKey === itemKey || pendingKey === "batch"; + const isSelected = selectedKeys.has(itemKey); + + return ( + + ); + })} +
+ )} + )}
@@ -408,6 +601,26 @@ export function QuickAddAgentPopover({
) : null} + {/* Multi-select confirm button */} + {multiSelectMode && selectedKeys.size > 0 ? ( +
+ +
+ ) : null} +