diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index c2ff0734e3..500685f23f 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -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 (
- + + + [...(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({

)}
+ {canAddAgents ? ( + setIsSidebarFullDialogOpen(true)} + > + + + ) : null} {changeRoleError ? ( @@ -319,6 +383,18 @@ export function MembersSidebar({ }} open={editRespondToAgent !== null} /> + {canAddAgents ? ( + + ) : null} ); } diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx new file mode 100644 index 0000000000..8b824493d9 --- /dev/null +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -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(null); + const [errorMessage, setErrorMessage] = React.useState(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 ( + + {children} + +
+
+

+ Add agent +

+
+ +
+ {isLoading ? ( +
+ +
+ ) : items.length === 0 ? ( +
+ No agents available. +
+ ) : ( +
+ {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 ( + + ); + })} +
+ )} +
+ + {errorMessage ? ( +
+

{errorMessage}

+
+ ) : null} + +
+ +
+
+
+
+ ); +} + +// ── 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 ( +
+ {avatarUrl ? ( + {label} + ) : ( + + {initials} + + )} + {isRunning ? ( + + ) : null} +
+ ); +}