diff --git a/desktop/src/features/agents/lib/sortProviders.ts b/desktop/src/features/agents/lib/sortProviders.ts new file mode 100644 index 0000000000..2bc9f3fd8b --- /dev/null +++ b/desktop/src/features/agents/lib/sortProviders.ts @@ -0,0 +1,18 @@ +import type { AcpProvider } from "@/shared/api/types"; + +/** + * Sort ACP providers with "goose" first, then alphabetically by label. + * Used by any surface that presents a provider list to the user. + */ +export function sortProviders( + providers: readonly AcpProvider[], +): AcpProvider[] { + return [...providers].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); + }); +} diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index c2ff0734e3..8017924c31 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -9,15 +9,19 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; +import { sortProviders } from "@/features/agents/lib/sortProviders"; import { useChannelMembersQuery } from "@/features/channels/hooks"; 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; currentPubkey?: string; + isAddBotDialogOpen?: boolean; + onAddBotDialogOpenChange?: (open: boolean) => void; onManageChannel: () => void; onToggleMembers: () => void; }; @@ -25,10 +29,16 @@ type ChannelMembersBarProps = { export function ChannelMembersBar({ channel, currentPubkey, + isAddBotDialogOpen, + onAddBotDialogOpenChange, onManageChannel, onToggleMembers, }: ChannelMembersBarProps) { - const [isAddBotOpen, setIsAddBotOpen] = React.useState(false); + // Dialog state: controlled externally if props provided, otherwise local. + const [localIsAddBotOpen, setLocalIsAddBotOpen] = React.useState(false); + const isAddBotOpen = isAddBotDialogOpen ?? localIsAddBotOpen; + const setIsAddBotOpen = onAddBotDialogOpenChange ?? setLocalIsAddBotOpen; + const [isQuickAddOpen, setIsQuickAddOpen] = React.useState(false); const { startHuddle, isStarting: isStartingHuddle } = useHuddle(); const queryClient = useQueryClient(); const membersQuery = useChannelMembersQuery(channel.id); @@ -39,16 +49,7 @@ export function ChannelMembersBar({ const members = membersQuery.data ?? []; const memberCount = membersQuery.data?.length ?? channel.memberCount; const providers = 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); - }), + () => sortProviders(providersQuery.data ?? []), [providersQuery.data], ); const normalizedCurrentPubkey = currentPubkey @@ -73,7 +74,8 @@ export function ChannelMembersBar({ previousChannelIdRef.current = channel.id; setIsAddBotOpen(false); - }, [channel.id]); + setIsQuickAddOpen(false); + }, [channel.id, setIsAddBotOpen]); const dialogErrorMessage = providersQuery.error instanceof Error @@ -87,20 +89,24 @@ export function ChannelMembersBar({ return (
- + + (null); const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false); + const [isAddBotDialogOpen, setIsAddBotDialogOpen] = React.useState(false); const [openThreadHeadId, setOpenThreadHeadId] = React.useState( null, ); @@ -436,7 +437,9 @@ export function ChannelScreen({ activeChannelTitle={activeChannelTitle} activeDmPresenceStatus={activeDmPresenceStatus} currentPubkey={currentPubkey} + isAddBotDialogOpen={isAddBotDialogOpen} isJoining={joinChannelMutation.isPending} + onAddBotDialogOpenChange={setIsAddBotDialogOpen} onJoinChannel={joinChannelMutation.mutateAsync} onManageChannel={openChannelManagement} onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)} @@ -530,6 +533,7 @@ export function ChannelScreen({ currentPubkey={currentPubkey} open={isMembersSidebarOpen} onOpenChange={setIsMembersSidebarOpen} + onOpenAddBotDialog={() => setIsAddBotDialogOpen(true)} onViewActivity={handleOpenAgentSession} /> diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 4673aa5586..23338b22a2 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -14,7 +14,9 @@ type ChannelScreenHeaderProps = { activeChannelTitle: string; activeDmPresenceStatus: PresenceStatus | null; currentPubkey?: string; + isAddBotDialogOpen?: boolean; isJoining?: boolean; + onAddBotDialogOpenChange?: (open: boolean) => void; onJoinChannel?: () => Promise; onManageChannel: () => void; onToggleMembers: () => void; @@ -26,7 +28,9 @@ export function ChannelScreenHeader({ activeChannelTitle, activeDmPresenceStatus, currentPubkey, + isAddBotDialogOpen, isJoining = false, + onAddBotDialogOpenChange, onJoinChannel, onManageChannel, onToggleMembers, @@ -56,6 +60,8 @@ export function ChannelScreenHeader({ diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index c7ffcc61fc..f3fa835926 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -1,3 +1,4 @@ +import { Plus } from "lucide-react"; import * as React from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { @@ -41,12 +42,14 @@ import { MembersSidebarAgentControls } from "./MembersSidebarAgentControls"; import { ChannelMemberInviteCard } from "./ChannelMemberInviteCard"; import { MembersSidebarMemberCard } from "./MembersSidebarMemberCard"; import { useMembersSidebarActions } from "./useMembersSidebarActions"; +import { QuickAddAgentPopover } from "./QuickAddAgentPopover"; type MembersSidebarProps = { channel: Channel | null; currentPubkey?: string; open: boolean; onOpenChange: (open: boolean) => void; + onOpenAddBotDialog?: () => void; onViewActivity?: (pubkey: string) => void; }; @@ -55,6 +58,7 @@ export function MembersSidebar({ currentPubkey, open, onOpenChange, + onOpenAddBotDialog, onViewActivity, }: MembersSidebarProps) { const channelId = channel?.id ?? null; @@ -98,6 +102,14 @@ 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 managedAgentByPubkey = React.useMemo( () => new Map( @@ -298,6 +310,29 @@ export function MembersSidebar({

)}
+ {canAddAgents ? ( + { + setIsSidebarQuickAddOpen(false); + onOpenAddBotDialog?.(); + }} + > + + + ) : null} {changeRoleError ? ( diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx new file mode 100644 index 0000000000..b3ffbe4a7b --- /dev/null +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -0,0 +1,460 @@ +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 RunningAvailableItem = { + kind: "running-available"; + agent: ManagedAgent; + persona: AgentPersona | null; + label: string; + avatarUrl: string | null; +}; + +type RunningInChannelItem = { + kind: "running-in-channel"; + agent: ManagedAgent; + persona: AgentPersona | null; + label: string; + avatarUrl: string | null; +}; + +type PersonaItem = { + kind: "persona"; + persona: AgentPersona; + label: string; + avatarUrl: string | null; +}; + +type QuickAddAgentItem = + | RunningAvailableItem + | RunningInChannelItem + | PersonaItem; + +type QuickAddAgentPopoverProps = { + channelId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onMoreOptions: () => void; + children: React.ReactNode; +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getItemKey(item: QuickAddAgentItem): string { + switch (item.kind) { + case "persona": + return `persona:${item.persona.id}`; + case "running-available": + case "running-in-channel": + return `agent:${item.agent.pubkey}`; + } +} + +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"; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export function QuickAddAgentPopover({ + channelId, + open, + onOpenChange, + 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 membersQuery = useChannelMembersQuery( + channelId, + open && channelId !== null, + ); + 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 whose agent is already in the channel (exclude from "available" list) + 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) ?? null) + : null; + 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) ?? null) + : null; + 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) { + if (!channelId) return; + 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) { + if (!channelId) return; + 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; + } + + const usedNames = new Set(managedAgents.map((a) => a.name)); + const instanceName = safeBotName(persona, 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 (!channelId) return; + + if (item.kind === "running-available") { + void handleAddRunningAgent(item.agent); + } else { + void handleAddPersona(item.persona); + } + } + + const isLoading = + managedAgentsQuery.isLoading || + personasQuery.isLoading || + providersQuery.isLoading; + + // Don't render the popover content when there's no channel + if (!channelId) { + return <>{children}; + } + + return ( + + {children} + +
{ + const container = e.currentTarget; + const buttons = Array.from( + container.querySelectorAll( + "[data-quick-add-item]:not([disabled])", + ), + ); + if (buttons.length === 0) return; + const focused = document.activeElement as HTMLElement | null; + const currentIdx = focused + ? buttons.indexOf(focused as HTMLButtonElement) + : -1; + + if (e.key === "ArrowDown") { + e.preventDefault(); + const next = currentIdx < buttons.length - 1 ? currentIdx + 1 : 0; + buttons[next]?.focus(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const prev = currentIdx > 0 ? currentIdx - 1 : buttons.length - 1; + buttons[prev]?.focus(); + } + }} + > +
+

+ Add agent +

+
+ +
+ {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; + + 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} +
+ ); +} diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 90f5a16b14..2a079af248 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -83,6 +83,7 @@ async function addGenericAgent( await page.getByTestId(`channel-${channelName}`).click(); await expect(page.getByTestId("chat-title")).toHaveText(channelName); await page.getByTestId("channel-add-bot-trigger").click(); + await page.getByTestId("quick-add-more-options").click(); await expect(page.getByRole("heading", { name: "Add agents" })).toBeVisible(); await page.getByRole("button", { name: "Generic" }).click(); await page.locator("#channel-generic-name").fill(agentName); @@ -852,6 +853,7 @@ test("open-channel members can add agents from the header", async ({ await expect(addAgentTrigger).toBeEnabled(); await addAgentTrigger.click(); + await page.getByTestId("quick-add-more-options").click(); await expect(page.getByRole("heading", { name: "Add agents" })).toBeVisible(); await expect(page.getByTestId("add-channel-bot-dialog-header")).toBeVisible(); await expect(