diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index dc154f0ca..f5f4de3ec 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -34,7 +34,7 @@ const overrides = new Map([ ["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests ["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests ["src/app/AppShell.tsx", 815], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager) - ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation + ["src/features/channels/hooks.ts", 560], // canvas query + mutation hooks + DM hide mutation + optimistic remove ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], ["src/features/channels/ui/ChannelPane.tsx", 520], // composer/timeline/sidebar orchestration + anchored agent activity footers ["src/features/channels/ui/ChannelScreen.tsx", 550], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification @@ -55,7 +55,7 @@ const overrides = new Map([ ["src-tauri/src/managed_agents/types.rs", 715], // ManagedAgentRecord/Summary + Create/Update request structs + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field ["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery) ["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh - ["src/features/agents/hooks.ts", 540], // agent query/mutation surface now includes built-in persona library activation + useUpdateManagedAgentMutation + ["src/features/agents/hooks.ts", 610], // agent query/mutation surface + optimistic member updates + reuse-guard context fetch ["src/features/agents/ui/AgentsView.tsx", 880], // remote agent lifecycle controls + persona/team management + persona import-update dialog wiring + built-in catalog/library state orchestration ["src/features/agents/ui/UnifiedAgentsSection.tsx", 570], // unified persona-grouped agent view with collapsible groups, bulk actions, drag-drop import, empty/loading states ["src/features/agents/ui/ManagedAgentRow.tsx", 530], // EditAgentDialog integration + provider/local branching diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 0fdb599d1..0609d3ab9 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -13,6 +13,7 @@ import { discoverAcpProviders, discoverBackendProviders, discoverManagedAgentPrereqs, + getChannelMembers, getManagedAgentLog, listManagedAgents, listRelayAgents, @@ -38,6 +39,7 @@ import { import type { AgentPersona, AgentTeam, + ChannelMember, CreateManagedAgentInput, CreatePersonaInput, CreateTeamInput, @@ -46,6 +48,7 @@ import type { UpdatePersonaInput, UpdateTeamInput, } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import type { AttachManagedAgentToChannelInput, AttachManagedAgentToChannelResult, @@ -356,6 +359,29 @@ export function useAttachManagedAgentToChannelMutation( return attachManagedAgentToChannel(channelId, input); }, + onMutate: async (input) => { + if (!channelId) return; + const membersKey = ["channels", channelId, "members"]; + await queryClient.cancelQueries({ queryKey: membersKey }); + const previous = + queryClient.getQueryData(membersKey) ?? []; + const optimistic: ChannelMember = { + pubkey: normalizePubkey(input.agent.pubkey), + role: input.role ?? "bot", + joinedAt: new Date().toISOString(), + displayName: input.agent.name, + }; + queryClient.setQueryData(membersKey, [ + ...previous, + optimistic, + ]); + return { previous, membersKey }; + }, + onError: (_err, _vars, context) => { + if (context?.membersKey) { + queryClient.setQueryData(context.membersKey, context.previous); + } + }, onSettled: async () => { await invalidateAgentQueries(queryClient, channelId); }, @@ -392,7 +418,39 @@ export function useCreateChannelManagedAgentMutation(channelId: string | null) { throw new Error("No channel selected."); } - return createChannelManagedAgent(channelId, input); + const [managedAgents, members] = await Promise.all([ + listManagedAgents(), + getChannelMembers(channelId), + ]); + const channelMemberPubkeys = new Set( + members.map((m) => normalizePubkey(m.pubkey)), + ); + + return createChannelManagedAgent(channelId, input, { + managedAgents, + channelMemberPubkeys, + }); + }, + onSuccess: (result) => { + if (!channelId) return; + const membersKey = ["channels", channelId, "members"]; + const current = + queryClient.getQueryData(membersKey) ?? []; + const alreadyPresent = current.some( + (m) => + normalizePubkey(m.pubkey) === normalizePubkey(result.agent.pubkey), + ); + if (!alreadyPresent) { + queryClient.setQueryData(membersKey, [ + ...current, + { + pubkey: normalizePubkey(result.agent.pubkey), + role: "bot", + joinedAt: new Date().toISOString(), + displayName: result.agent.name, + }, + ]); + } }, onSettled: async () => { await invalidateAgentQueries(queryClient, channelId); @@ -415,6 +473,29 @@ export function useCreateChannelManagedAgentsMutation( return createChannelManagedAgents(channelId, inputs); }, + onSuccess: (result) => { + if (!channelId) return; + const membersKey = ["channels", channelId, "members"]; + const current = + queryClient.getQueryData(membersKey) ?? []; + const existingPubkeys = new Set( + current.map((m) => normalizePubkey(m.pubkey)), + ); + const newMembers: ChannelMember[] = result.successes + .filter((s) => !existingPubkeys.has(normalizePubkey(s.agent.pubkey))) + .map((s) => ({ + pubkey: normalizePubkey(s.agent.pubkey), + role: "bot" as const, + joinedAt: new Date().toISOString(), + displayName: s.agent.name, + })); + if (newMembers.length > 0) { + queryClient.setQueryData(membersKey, [ + ...current, + ...newMembers, + ]); + } + }, onSettled: async () => { await invalidateAgentQueries(queryClient, channelId); }, diff --git a/desktop/src/features/agents/lib/sortProviders.ts b/desktop/src/features/agents/lib/sortProviders.ts new file mode 100644 index 000000000..2bc9f3fd8 --- /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/hooks.ts b/desktop/src/features/channels/hooks.ts index dab02e486..a81d05d24 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -29,12 +29,14 @@ import type { AddChannelMembersInput, Channel, ChannelDetail, + ChannelMember, CreateChannelInput, OpenDmInput, SetChannelPurposeInput, SetChannelTopicInput, UpdateChannelInput, } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; export const channelsQueryKey = ["channels"] as const; const channelDetailQueryKey = (channelId: string) => @@ -410,6 +412,25 @@ export function useRemoveChannelMemberMutation(channelId: string | null) { await removeChannelMemberWithManagedAgentCleanup(channelId, pubkey); }, + onMutate: async (pubkey) => { + if (!channelId) return; + const membersKey = channelMembersQueryKey(channelId); + await queryClient.cancelQueries({ queryKey: membersKey }); + const previous = + queryClient.getQueryData(membersKey) ?? []; + queryClient.setQueryData( + membersKey, + previous.filter( + (m) => normalizePubkey(m.pubkey) !== normalizePubkey(pubkey), + ), + ); + return { previous, membersKey }; + }, + onError: (_err, _vars, context) => { + if (context?.membersKey) { + queryClient.setQueryData(context.membersKey, context.previous); + } + }, onSettled: async () => { await Promise.all([ invalidateChannelState(queryClient, channelId), diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx index 45c33d59e..2fbc91ec3 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -477,7 +477,7 @@ export function AddChannelBotDialog({ footerClassName="justify-end gap-2" footerTestId="add-channel-bot-dialog-footer" headerTestId="add-channel-bot-dialog-header" - scrollAreaClassName="space-y-5" + scrollAreaClassName="space-y-6" scrollAreaTestId="add-channel-bot-dialog-scroll-area" title="Add agents" > diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index c2ff0734e..8017924c3 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 4673aa558..23338b22a 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 c7ffcc61f..19563834c 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; @@ -92,12 +96,31 @@ export function MembersSidebar({ enabled: open && rawMembers.length > 0, }); + const presence = memberPresenceQuery.data; + const sortedBots = React.useMemo(() => { + if (!presence) return bots; + return [...bots].sort((a, b) => { + const aOnline = presence[normalizePubkey(a.pubkey)] === "online" ? 0 : 1; + const bOnline = presence[normalizePubkey(b.pubkey)] === "online" ? 0 : 1; + if (aOnline !== bOnline) return aOnline - bOnline; + return formatMemberName(a).localeCompare(formatMemberName(b)); + }); + }, [bots, presence]); + const selfMember = rawMembers.find((member) => member.pubkey === currentPubkey) ?? null; const canManageMembers = 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( @@ -110,11 +133,11 @@ export function MembersSidebar({ ); const controllableManagedBots = React.useMemo( () => - bots.flatMap((member) => { + sortedBots.flatMap((member) => { const agent = managedAgentByPubkey.get(normalizePubkey(member.pubkey)); return agent ? [agent] : []; }), - [bots, managedAgentByPubkey], + [sortedBots, managedAgentByPubkey], ); const canRemoveMember = React.useCallback( (member: ChannelMember) => { @@ -129,7 +152,7 @@ export function MembersSidebar({ ); const removableManagedBots = React.useMemo( () => - bots.flatMap((member) => { + sortedBots.flatMap((member) => { if (!canRemoveMember(member)) { return []; } @@ -137,7 +160,7 @@ export function MembersSidebar({ const agent = managedAgentByPubkey.get(normalizePubkey(member.pubkey)); return agent ? [agent] : []; }), - [bots, canRemoveMember, managedAgentByPubkey], + [sortedBots, canRemoveMember, managedAgentByPubkey], ); const { actionErrorMessage, @@ -267,29 +290,51 @@ export function MembersSidebar({

Bots

- {bots.length} + {sortedBots.length} - {hasControllableManagedBots ? ( - { - void handleRemoveAll(); - }} - onRespawnAll={() => { - void handleRespawnAll(); - }} - onStopAll={() => { - void handleStopAll(); - }} - /> - ) : null} +
+ {canAddAgents ? ( + { + setIsSidebarQuickAddOpen(false); + onOpenAddBotDialog?.(); + }} + > + + + ) : null} + {hasControllableManagedBots ? ( + { + void handleRemoveAll(); + }} + onRespawnAll={() => { + void handleRespawnAll(); + }} + onStopAll={() => { + void handleStopAll(); + }} + /> + ) : null} +
- {bots.length > 0 ? ( - bots.map((member) => renderMemberCard(member, true)) + {sortedBots.length > 0 ? ( + sortedBots.map((member) => renderMemberCard(member, true)) ) : (

{membersQuery.isLoading diff --git a/desktop/src/features/channels/ui/MembersSidebarAgentControls.tsx b/desktop/src/features/channels/ui/MembersSidebarAgentControls.tsx index 303f6748a..6aa925dc1 100644 --- a/desktop/src/features/channels/ui/MembersSidebarAgentControls.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarAgentControls.tsx @@ -31,7 +31,7 @@ export function MembersSidebarAgentControls({ + ) : null} + {isPending ? ( + + ) : null} +

+ + + ); +}); diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx new file mode 100644 index 000000000..99d37933e --- /dev/null +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -0,0 +1,274 @@ +import { Settings2 } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import * as React from "react"; + +import { Toggle } from "@/shared/ui/toggle"; +import { Button } from "@/shared/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Spinner } from "@/shared/ui/spinner"; +import { getItemKey, useQuickAddAgentItems } from "./useQuickAddAgentItems"; +import { useQuickAddAgentActions } from "./useQuickAddAgentActions"; +import { QuickAddAgentItemRow } from "./QuickAddAgentItemRow"; + +// ── Component ───────────────────────────────────────────────────────────────── + +type QuickAddAgentPopoverProps = { + channelId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onMoreOptions: () => void; + children: React.ReactNode; +}; + +export function QuickAddAgentPopover({ + channelId, + open, + onOpenChange, + onMoreOptions, + children, +}: QuickAddAgentPopoverProps) { + const { + items, + isLoading, + managedAgents, + personas, + providers, + defaultProvider, + usableTeams, + pushRecent, + } = useQuickAddAgentItems(channelId, open && channelId !== null); + + const { + pendingKey, + errorMessage, + selectMode, + setSelectMode, + selectedKeys, + selectedTeamIds, + reset, + handleCancelSelect, + handleTeamToggle, + handleBatchAdd, + handleItemClick, + removeMutation, + } = useQuickAddAgentActions({ + channelId, + items, + managedAgents, + personas, + providers, + defaultProvider, + usableTeams, + pushRecent, + }); + + // Reset state when popover closes + React.useEffect(() => { + if (!open) reset(); + }, [open, reset]); + + const multiSelectActive = selectMode && selectedKeys.size > 0; + + if (!channelId) { + return <>{children}; + } + + return ( + + {children} + + {/* biome-ignore lint/a11y/useSemanticElements: composite widget with roving focus */} +
{ + 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(); + } + }} + > + {/* Header */} +
+

+ Add agent +

+ {usableTeams.length > 0 ? ( +
+ +
+ ) : null} +
+ + {/* Team toggles row */} + +
+
+ {usableTeams.map((team, index) => ( + + + handleTeamToggle(team, pressed) + } + pressed={selectedTeamIds.has(team.id)} + size="sm" + variant="subtle" + > + {team.name} + + + ))} +
+
+
+ + + {/* Scrollable content */} + + {isLoading ? ( +
+ +
+ ) : items.length === 0 ? ( +
+ No agents available. +
+ ) : ( +
+ {items.map((item) => { + const itemKey = getItemKey(item); + return ( + handleItemClick(item)} + onRemove={(pubkey) => removeMutation.mutate(pubkey)} + /> + ); + })} +
+ )} +
+ + {errorMessage ? ( +
+

{errorMessage}

+
+ ) : null} + +
+ +
+ + + {multiSelectActive ? ( + + + + ) : null} + +
+ + + ); +} diff --git a/desktop/src/features/channels/ui/useQuickAddAgentActions.ts b/desktop/src/features/channels/ui/useQuickAddAgentActions.ts new file mode 100644 index 000000000..e969e3545 --- /dev/null +++ b/desktop/src/features/channels/ui/useQuickAddAgentActions.ts @@ -0,0 +1,294 @@ +import * as React from "react"; + +import { + useAttachManagedAgentToChannelMutation, + useCreateChannelManagedAgentMutation, + useCreateChannelManagedAgentsMutation, +} from "@/features/agents/hooks"; +import { useRemoveChannelMemberMutation } from "@/features/channels/hooks"; +import { resolvePersonaProvider } from "@/features/agents/lib/resolvePersonaProvider"; +import { pickBotName } from "@/features/agents/lib/pickBotName"; +import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas"; +import type { + AcpProvider, + AgentPersona, + AgentTeam, + ManagedAgent, +} from "@/shared/api/types"; +import { getItemKey, type QuickAddAgentItem } from "./useQuickAddAgentItems"; + +function safeBotName(persona: AgentPersona, usedNames: Set): string { + const pool = persona.namePool ?? []; + const name = pickBotName(pool, usedNames); + if (name && name.trim().length > 0) return name; + return persona.displayName || "Agent"; +} + +type UseQuickAddAgentActionsParams = { + channelId: string | null; + items: QuickAddAgentItem[]; + managedAgents: ManagedAgent[]; + personas: AgentPersona[]; + providers: AcpProvider[]; + defaultProvider: AcpProvider | null; + usableTeams: AgentTeam[]; + pushRecent: (personaId: string) => void; +}; + +export function useQuickAddAgentActions({ + channelId, + items, + managedAgents, + personas, + providers, + defaultProvider, + usableTeams, + pushRecent, +}: UseQuickAddAgentActionsParams) { + const attachMutation = useAttachManagedAgentToChannelMutation(channelId); + const createMutation = useCreateChannelManagedAgentMutation(channelId); + const batchCreateMutation = useCreateChannelManagedAgentsMutation(channelId); + const removeMutation = useRemoveChannelMemberMutation(channelId); + + 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 [selectedTeamIds, setSelectedTeamIds] = React.useState>( + new Set(), + ); + + // Reset state when popover closes (caller should pass `open`) + const reset = React.useCallback(() => { + setPendingKey(null); + setErrorMessage(null); + setSelectMode(false); + setSelectedKeys(new Set()); + setSelectedTeamIds(new Set()); + }, []); + + // ── Single-add handlers ───────────────────────────────────────────────── + + 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); + setPendingKey(null); + } 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); + setPendingKey(null); + } catch (err) { + setErrorMessage( + err instanceof Error ? err.message : "Failed to add agent.", + ); + setPendingKey(null); + } + } + + // ── Multi-select handlers ─────────────────────────────────────────────── + + function handleCancelSelect() { + setSelectMode(false); + setSelectedKeys(new Set()); + setSelectedTeamIds(new Set()); + } + + function toggleSelection(key: string) { + setSelectedKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + setSelectedTeamIds((prevTeams) => { + const nextTeams = new Set(prevTeams); + for (const team of usableTeams) { + if (!nextTeams.has(team.id)) continue; + const resolution = resolveTeamPersonas(team, personas); + const allSelected = resolution.resolvedPersonas.every((p) => { + const runningItem = items.find( + (i) => + i.kind === "running-available" && i.agent.personaId === p.id, + ); + const ik = runningItem + ? getItemKey(runningItem) + : (() => { + const pi = items.find( + (i) => i.kind === "persona" && i.persona.id === p.id, + ); + return pi ? getItemKey(pi) : null; + })(); + return ik ? next.has(ik) : true; + }); + if (!allSelected) nextTeams.delete(team.id); + } + return nextTeams; + }); + return next; + }); + } + + function handleTeamToggle(team: AgentTeam, pressed: boolean) { + const resolution = resolveTeamPersonas(team, personas); + const memberKeys: string[] = []; + for (const persona of resolution.resolvedPersonas) { + const runningItem = items.find( + (i) => + i.kind === "running-available" && i.agent.personaId === persona.id, + ); + if (runningItem) { + memberKeys.push(getItemKey(runningItem)); + } else { + const personaItem = items.find( + (i) => i.kind === "persona" && i.persona.id === persona.id, + ); + if (personaItem) memberKeys.push(getItemKey(personaItem)); + } + } + setSelectedTeamIds((prev) => { + const next = new Set(prev); + if (pressed) next.add(team.id); + else next.delete(team.id); + return next; + }); + setSelectedKeys((prev) => { + const next = new Set(prev); + for (const key of memberKeys) { + if (pressed) next.add(key); + else next.delete(key); + } + 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); + } + setPendingKey(null); + setSelectMode(false); + setSelectedKeys(new Set()); + setSelectedTeamIds(new Set()); + } 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 (selectMode) { + toggleSelection(getItemKey(item)); + } else if (item.kind === "running-available") { + void handleAddRunningAgent(item.agent); + } else { + void handleAddPersona(item.persona); + } + } + + return { + pendingKey, + errorMessage, + selectMode, + setSelectMode, + selectedKeys, + selectedTeamIds, + reset, + handleCancelSelect, + handleTeamToggle, + handleBatchAdd, + handleItemClick, + removeMutation, + }; +} diff --git a/desktop/src/features/channels/ui/useQuickAddAgentItems.ts b/desktop/src/features/channels/ui/useQuickAddAgentItems.ts new file mode 100644 index 000000000..b25845639 --- /dev/null +++ b/desktop/src/features/channels/ui/useQuickAddAgentItems.ts @@ -0,0 +1,213 @@ +import * as React from "react"; + +import { + useAcpProvidersQuery, + useManagedAgentsQuery, + usePersonasQuery, + useTeamsQuery, +} from "@/features/agents/hooks"; +import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { getActivePersonas } from "@/features/agents/lib/catalog"; +import { useBotRecents } from "@/features/agents/lib/useBotRecents"; +import { + getUsableTeams, + resolveTeamPersonas, +} from "@/features/agents/lib/teamPersonas"; +import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type RunningAvailableItem = { + kind: "running-available"; + agent: ManagedAgent; + persona: AgentPersona | null; + label: string; + avatarUrl: string | null; +}; + +export type RunningInChannelItem = { + kind: "running-in-channel"; + agent: ManagedAgent; + persona: AgentPersona | null; + label: string; + avatarUrl: string | null; +}; + +export type PersonaItem = { + kind: "persona"; + persona: AgentPersona; + label: string; + avatarUrl: string | null; +}; + +export type QuickAddAgentItem = + | RunningAvailableItem + | RunningInChannelItem + | PersonaItem; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +export 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}`; + } +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +export function useQuickAddAgentItems( + channelId: string | null, + enabled: boolean, +) { + const managedAgentsQuery = useManagedAgentsQuery(); + const personasQuery = usePersonasQuery(); + const providersQuery = useAcpProvidersQuery(); + const teamsQuery = useTeamsQuery(); + const membersQuery = useChannelMembersQuery(channelId, enabled); + const { recentIds, pushRecent } = useBotRecents(); + + 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 teams = teamsQuery.data ?? []; + + const channelMemberPubkeys = React.useMemo( + () => new Set(members.map((m) => normalizePubkey(m.pubkey))), + [members], + ); + + const usableTeams = React.useMemo(() => { + const allUsable = getUsableTeams(teams, personas); + // Filter out teams whose members are ALL already in the channel + return allUsable.filter((team) => { + const resolution = resolveTeamPersonas(team, personas); + return resolution.resolvedPersonas.some((persona) => { + // Check if this persona has a running agent not in channel, or no agent at all + const runningAgent = managedAgents.find( + (a) => + a.personaId === persona.id && + (a.status === "running" || a.status === "deployed"), + ); + if (runningAgent) { + return !channelMemberPubkeys.has( + normalizePubkey(runningAgent.pubkey), + ); + } + // Persona has no running agent — it's addable (will create new) + return true; + }); + }); + }, [teams, personas, managedAgents, channelMemberPubkeys]); + + const items: QuickAddAgentItem[] = React.useMemo(() => { + const result: QuickAddAgentItem[] = []; + + const runningAvailable = managedAgents.filter( + (agent) => + (agent.status === "running" || agent.status === "deployed") && + !channelMemberPubkeys.has(normalizePubkey(agent.pubkey)), + ); + + const runningInChannel = managedAgents.filter( + (agent) => + (agent.status === "running" || agent.status === "deployed") && + channelMemberPubkeys.has(normalizePubkey(agent.pubkey)), + ); + + 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), + ); + + 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 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, + }); + } + + 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, + }); + } + + 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]); + + const isLoading = + managedAgentsQuery.isLoading || + personasQuery.isLoading || + providersQuery.isLoading; + + return { + items, + isLoading, + managedAgents, + personas, + providers, + defaultProvider, + usableTeams, + teamsQuery, + pushRecent, + }; +} diff --git a/desktop/src/shared/ui/dropdown-menu.tsx b/desktop/src/shared/ui/dropdown-menu.tsx index 2e4476e00..7c64c7c05 100644 --- a/desktop/src/shared/ui/dropdown-menu.tsx +++ b/desktop/src/shared/ui/dropdown-menu.tsx @@ -63,7 +63,7 @@ const DropdownMenuContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", + "z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]", className, )} diff --git a/desktop/src/shared/ui/toggle.tsx b/desktop/src/shared/ui/toggle.tsx index 5a629e4b0..aec80a8c8 100644 --- a/desktop/src/shared/ui/toggle.tsx +++ b/desktop/src/shared/ui/toggle.tsx @@ -12,6 +12,8 @@ const toggleVariants = cva( default: "bg-transparent", outline: "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", + subtle: + "border border-input bg-transparent data-[state=on]:bg-primary/10 data-[state=on]:border-primary data-[state=on]:!text-foreground", }, size: { default: "h-9 px-3 min-w-9", diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 90f5a16b1..2a079af24 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(