From 0d56b5805f747f5538a2bc00a2040013f702e572 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 19 May 2026 22:01:54 -0700 Subject: [PATCH 01/62] feat: quick-add agent popover with two entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the direct + → full dialog flow with a lightweight Radix popover that shows available agents sorted by state (running > configured > catalog). One click to add. The full AddChannelBotDialog is still accessible via 'More options…' at the bottom of the popover. Two trigger points: - The existing + button in ChannelMembersBar now opens the popover - A new 'Add agent' button at the bottom of the sidebar Bots section The sidebar already had People/Bots section headers — the inline add affordance lives at the bottom of the Bots section. Actions: - Running agent not in channel → useAttachManagedAgentToChannelMutation - Persona without running agent → useCreateChannelManagedAgentMutation (leverages existing reuse logic from agentReuse.ts) The popover uses useBotRecents for smart ordering and pickBotName for instance naming. Force-new-instance stays buried in the full dialog. --- .../channels/ui/ChannelMembersBar.tsx | 33 +- .../features/channels/ui/MembersSidebar.tsx | 78 +++- .../channels/ui/QuickAddAgentPopover.tsx | 396 ++++++++++++++++++ 3 files changed, 493 insertions(+), 14 deletions(-) create mode 100644 desktop/src/features/channels/ui/QuickAddAgentPopover.tsx diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index c2ff0734e..500685f23 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 000000000..8b824493d --- /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} +
+ ); +} From d1b7aeb538edb5ec2ce13acaeab4613a2ecbe118 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 19 May 2026 22:13:13 -0700 Subject: [PATCH 02/62] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20criticals=20+=20mediums?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical fixes: 1. Null channelId guard — popover renders children-only (no popover content) when channelId is null. Action handlers also early-return. 2. pickBotName fallback — safeBotName() wraps pickBotName with a defensive fallback to persona.displayName or 'Agent'. 3. E2E test updates — tests now click through the quick-add popover's 'More options…' button before asserting on the full dialog. 4. Duplicate AddChannelBotDialog — removed the sidebar's own dialog instance. Dialog state is now lifted to ChannelScreen and passed down via props. Only one dialog instance exists (in ChannelMembersBar). Medium fixes: 5. Discriminated union types — QuickAddAgentItem is now a proper tagged union (RunningAvailableItem | RunningInChannelItem | PersonaItem). No more optional agent?/persona? fields. 6. Extracted sortProviders utility — shared between ChannelMembersBar and any future consumer. Single source of truth for provider ordering. 7. Emerald colors kept — matches existing presence pattern (bg-emerald-500) used throughout the codebase. No design token exists for 'running' status. 8. Keyboard navigation — added arrow-key roving focus via onKeyDown handler on the popover container, targeting [data-quick-add-item] buttons. 9. Eager queries — channel members query already gated by popover open state. Other queries (agents, personas, providers) are globally cached by ChannelMembersBar which is always mounted — no extra network cost. --- .../src/features/agents/lib/sortProviders.ts | 18 +++ .../channels/ui/ChannelMembersBar.tsx | 23 ++- .../features/channels/ui/ChannelScreen.tsx | 4 + .../channels/ui/ChannelScreenHeader.tsx | 6 + .../features/channels/ui/MembersSidebar.tsx | 55 +------ .../channels/ui/QuickAddAgentPopover.tsx | 150 +++++++++++++----- desktop/tests/e2e/channels.spec.ts | 2 + 7 files changed, 156 insertions(+), 102 deletions(-) create mode 100644 desktop/src/features/agents/lib/sortProviders.ts 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/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index 500685f23..8017924c3 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -9,6 +9,7 @@ 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"; @@ -19,6 +20,8 @@ import { QuickAddAgentPopover } from "./QuickAddAgentPopover"; type ChannelMembersBarProps = { channel: Channel; currentPubkey?: string; + isAddBotDialogOpen?: boolean; + onAddBotDialogOpenChange?: (open: boolean) => void; onManageChannel: () => void; onToggleMembers: () => void; }; @@ -26,10 +29,15 @@ 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(); @@ -41,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 @@ -76,7 +75,7 @@ export function ChannelMembersBar({ previousChannelIdRef.current = channel.id; setIsAddBotOpen(false); setIsQuickAddOpen(false); - }, [channel.id]); + }, [channel.id, setIsAddBotOpen]); const dialogErrorMessage = providersQuery.error instanceof Error diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 64902e2fe..870e4bead 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -85,6 +85,7 @@ export function ChannelScreen({ string | null >(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 36b0b64fe..f3fa83592 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -5,13 +5,7 @@ import { useAddChannelMembersMutation, useChannelMembersQuery, } from "@/features/channels/hooks"; -import { - useAcpProvidersQuery, - useBackendProvidersQuery, - useManagedAgentsQuery, - useRelayAgentsQuery, - useUpdateManagedAgentMutation, -} from "@/features/agents/hooks"; +import { useUpdateManagedAgentMutation } from "@/features/agents/hooks"; import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; import { @@ -48,7 +42,6 @@ import { MembersSidebarAgentControls } from "./MembersSidebarAgentControls"; import { ChannelMemberInviteCard } from "./ChannelMemberInviteCard"; import { MembersSidebarMemberCard } from "./MembersSidebarMemberCard"; import { useMembersSidebarActions } from "./useMembersSidebarActions"; -import { AddChannelBotDialog } from "./AddChannelBotDialog"; import { QuickAddAgentPopover } from "./QuickAddAgentPopover"; type MembersSidebarProps = { @@ -56,6 +49,7 @@ type MembersSidebarProps = { currentPubkey?: string; open: boolean; onOpenChange: (open: boolean) => void; + onOpenAddBotDialog?: () => void; onViewActivity?: (pubkey: string) => void; }; @@ -64,37 +58,13 @@ export function MembersSidebar({ currentPubkey, open, onOpenChange, + onOpenAddBotDialog, onViewActivity, }: MembersSidebarProps) { const channelId = channel?.id ?? null; const queryClient = useQueryClient(); const membersQuery = useChannelMembersQuery(channelId, open); const addMembersMutation = useAddChannelMembersMutation(channelId); - const providersQuery = useAcpProvidersQuery(); - const backendProvidersQuery = useBackendProvidersQuery(); - const sidebarManagedAgentsQuery = useManagedAgentsQuery(); - const sidebarRelayAgentsQuery = useRelayAgentsQuery(); - const sidebarProviders = 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); - }), - [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."); @@ -139,8 +109,6 @@ export function MembersSidebar({ const [isSidebarQuickAddOpen, setIsSidebarQuickAddOpen] = React.useState(false); - const [isSidebarFullDialogOpen, setIsSidebarFullDialogOpen] = - React.useState(false); const managedAgentByPubkey = React.useMemo( () => @@ -347,7 +315,10 @@ export function MembersSidebar({ channelId={channelId} open={isSidebarQuickAddOpen} onOpenChange={setIsSidebarQuickAddOpen} - onMoreOptions={() => setIsSidebarFullDialogOpen(true)} + onMoreOptions={() => { + setIsSidebarQuickAddOpen(false); + onOpenAddBotDialog?.(); + }} > - - + setIsAddBotOpen(true)} + > + + + + + - setIsAddBotOpen(true)} - > - - - + + ) : null} + {hasControllableManagedBots ? ( + { + void handleRemoveAll(); + }} + onRespawnAll={() => { + void handleRespawnAll(); + }} + onStopAll={() => { + void handleStopAll(); + }} + /> + ) : null} +
{bots.length > 0 ? ( @@ -310,29 +332,6 @@ export function MembersSidebar({

)}
- {canAddAgents ? ( - { - setIsSidebarQuickAddOpen(false); - onOpenAddBotDialog?.(); - }} - > - - - ) : null} {changeRoleError ? ( 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({ - ); - })} - + <> + {/* Teams section */} + {usableTeams.length > 0 ? ( +
+ {usableTeams.map((team) => { + const resolution = resolveTeamPersonas(team, personas); + return ( + + ); + })} +
+ ) : null} + + {/* 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} +
+ ) : null}
{/* Scrollable content area — max-height clips mid-item to hint at more */} @@ -601,26 +617,6 @@ export function QuickAddAgentPopover({ ) : null} - {/* Multi-select confirm button */} - {multiSelectMode && selectedKeys.size > 0 ? ( -
- -
- ) : null} -
+ ); + } + return ( {children} @@ -496,118 +623,61 @@ export function QuickAddAgentPopover({ ) : null}
- {/* Scrollable content area — max-height clips mid-item to hint at more */} + {/* Scrollable content — clips mid-item to hint at more */}
{isLoading ? (
+ ) : items.length === 0 ? ( +
+ No agents available. +
) : ( - <> - {/* Teams section */} - {usableTeams.length > 0 ? ( -
- {usableTeams.map((team) => { - const resolution = resolveTeamPersonas(team, personas); - return ( - - ); - })} -
- ) : null} - - {/* Agents section */} - {items.length === 0 ? ( -
- No agents available. +
+ {/* Team groups */} + {teamGroups.map(({ team, items: teamItems }) => ( +
+ {/* Team header */} + + {/* Team members (indented) */} + {teamItems.map((item) => renderAgentRow(item, true))}
- ) : ( -
- {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 ( - - ); - })} + ))} + + {/* Ungrouped section */} + {ungroupedItems.length > 0 ? ( +
0 && "border-t pt-1")}> + {teamGroups.length > 0 ? ( +
+ + Ungrouped + +
+ ) : null} + {ungroupedItems.map((item) => renderAgentRow(item, false))}
- )} - + ) : null} +
)}
From 923bb3897d0dfe43e1b92438da7170c46ec1d6f5 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 11:42:25 -0700 Subject: [PATCH 12/62] Simplify popover: strip teams/multi-select, back to flat quick-add list Remove team headers, nesting, multi-select mode, checkboxes, batch mutations, and team-related imports. The popover returns to its core purpose: a flat, smartly-ordered list where single-click immediately adds an agent. Teams and batch selection will live in the 'More options' dialog instead. Keeps: overflow-hidden fix, role=menu, smart ordering (running first, recents, then available), scroll container with mid-item clip. --- .../channels/ui/QuickAddAgentPopover.tsx | 379 +++--------------- 1 file changed, 45 insertions(+), 334 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index bcbe82953..fd8855cd5 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -1,29 +1,22 @@ -import { Check, Settings2, Users } from "lucide-react"; +import { Check, Settings2 } 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 { - getUsableTeams, - resolveTeamPersonas, -} from "@/features/agents/lib/teamPersonas"; -import type { AgentPersona, AgentTeam, ManagedAgent } from "@/shared/api/types"; +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 { Button } from "@/shared/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Spinner } from "@/shared/ui/spinner"; @@ -77,17 +70,6 @@ function getItemKey(item: QuickAddAgentItem): string { } } -/** Get the persona ID associated with an item (for team membership checks). */ -function getItemPersonaId(item: QuickAddAgentItem): string | null { - switch (item.kind) { - case "persona": - return item.persona.id; - case "running-available": - case "running-in-channel": - return item.agent.personaId ?? null; - } -} - function safeBotName(persona: AgentPersona, usedNames: Set): string { const pool = persona.namePool ?? []; const name = pickBotName(pool, usedNames); @@ -107,22 +89,16 @@ 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 [multiSelectMode, setMultiSelectMode] = React.useState(false); - const [selectedKeys, setSelectedKeys] = React.useState>( - new Set(), - ); const managedAgents = managedAgentsQuery.data ?? []; const personas = React.useMemo( @@ -132,20 +108,13 @@ 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 full item list (flat, for mutations) + // Build the sorted item list const items: QuickAddAgentItem[] = React.useMemo(() => { const result: QuickAddAgentItem[] = []; @@ -231,50 +200,14 @@ export function QuickAddAgentPopover({ return result; }, [managedAgents, personas, channelMemberPubkeys, recentIds]); - // ── Grouped layout: team sections + ungrouped ───────────────────────────── - - // Set of all persona IDs that belong to at least one usable team - const teamedPersonaIds = React.useMemo(() => { - const ids = new Set(); - for (const team of usableTeams) { - for (const pid of team.personaIds) { - ids.add(pid); - } - } - return ids; - }, [usableTeams]); - - // Items grouped by team - const teamGroups = React.useMemo(() => { - return usableTeams.map((team) => { - const memberItems = items.filter((item) => { - const personaId = getItemPersonaId(item); - return personaId !== null && team.personaIds.includes(personaId); - }); - return { team, items: memberItems }; - }); - }, [usableTeams, items]); - - // Ungrouped items (not in any team) - const ungroupedItems = React.useMemo(() => { - return items.filter((item) => { - const personaId = getItemPersonaId(item); - return personaId === null || !teamedPersonaIds.has(personaId); - }); - }, [items, teamedPersonaIds]); - // Reset state when popover closes React.useEffect(() => { 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}`; @@ -333,159 +266,15 @@ export function QuickAddAgentPopover({ } } - // ── Multi-select handlers ───────────────────────────────────────────────── - - function enterMultiSelect(key: string) { - setMultiSelectMode(true); - setSelectedKeys((prev) => { - const next = new Set(prev); - next.add(key); - return next; - }); - } - - 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) { - setMultiSelectMode(false); - } - return next; - }); - } - - function handleTeamClick(team: AgentTeam) { - const resolution = resolveTeamPersonas(team, personas); - - setMultiSelectMode(true); - setSelectedKeys((prev) => { - const next = new Set(prev); - for (const persona of resolution.resolvedPersonas) { - // Find the 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 && personaItem.kind !== "running-in-channel") { - 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 (multiSelectMode) { - toggleSelection(getItemKey(item)); - } else { - // Single-click fast path — add immediately - if (item.kind === "running-available") { - void handleAddRunningAgent(item.agent); - } else { - void handleAddPersona(item.persona); - } - } - } - - function handleCheckboxClick(e: React.MouseEvent, item: QuickAddAgentItem) { - e.stopPropagation(); - if (item.kind === "running-in-channel") return; - if (pendingKey) return; - - const key = getItemKey(item); - if (multiSelectMode) { - toggleSelection(key); + if (item.kind === "running-available") { + void handleAddRunningAgent(item.agent); } else { - // Clicking checkbox enters multi-select mode - enterMultiSelect(key); + void handleAddPersona(item.persona); } } @@ -498,75 +287,6 @@ export function QuickAddAgentPopover({ return <>{children}; } - // ── Render helpers ──────────────────────────────────────────────────────── - - function renderAgentRow(item: QuickAddAgentItem, indented: boolean) { - const itemKey = getItemKey(item); - const isInChannel = item.kind === "running-in-channel"; - const isItemPending = pendingKey === itemKey || pendingKey === "batch"; - const isSelected = selectedKeys.has(itemKey); - - return ( - - ); - } - return ( {children} @@ -602,25 +322,10 @@ export function QuickAddAgentPopover({ } }} > -
+

Add agent

- {multiSelectMode && selectedKeys.size > 0 ? ( - - ) : null}
{/* Scrollable content — clips mid-item to hint at more */} @@ -639,44 +344,50 @@ export function QuickAddAgentPopover({ className="py-1" role="listbox" > - {/* Team groups */} - {teamGroups.map(({ team, items: teamItems }) => ( -
- {/* Team header */} + {items.map((item) => { + const itemKey = getItemKey(item); + const isInChannel = item.kind === "running-in-channel"; + const isItemPending = pendingKey === itemKey; + + return ( - {/* Team members (indented) */} - {teamItems.map((item) => renderAgentRow(item, true))} -
- ))} - - {/* Ungrouped section */} - {ungroupedItems.length > 0 ? ( -
0 && "border-t pt-1")}> - {teamGroups.length > 0 ? ( -
- - Ungrouped + {isInChannel ? ( + + ) : item.kind === "running-available" ? ( + + running -
- ) : null} - {ungroupedItems.map((item) => renderAgentRow(item, false))} -
- ) : null} + ) : null} + {isItemPending ? ( + + ) : null} + + ); + })}
)}
From 3acc22067974707ad4ee78def8010804e3080327 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 11:45:52 -0700 Subject: [PATCH 13/62] Reshape AddChannelBotDialog with progressive disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the dialog from a flat wall of fields into three layers: 1. Primary (always visible): Persona/team selection + runtime picker. These are the two decisions that matter most. 2. Customize (collapsed): Bot name, system prompt, respond-to allowlist. Power user controls that don't block the happy path. 3. Advanced (collapsed): Backend provider selection, provider config. Expert territory hidden by default. Uses a simple DisclosureSection component with chevron toggle. All existing functionality preserved — just layered behind progressive disclosure. Description updated to match the new vibe. --- .../channels/ui/AddChannelBotDialog.tsx | 255 +++++++++++------- 1 file changed, 154 insertions(+), 101 deletions(-) diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx index 45c33d59e..231e0973b 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, ChevronDown } from "lucide-react"; +import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; import * as React from "react"; import { @@ -43,6 +43,7 @@ import { getActivePersonas } from "@/features/agents/lib/catalog"; import { getUsableTeams } from "@/features/agents/lib/teamPersonas"; import { useLastRuntimeProvider } from "@/features/agents/lib/useLastRuntimeProvider"; import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField"; +import { cn } from "@/shared/lib/cn"; type AddChannelBotDialogProps = { backendProviders?: BackendProviderCandidate[]; @@ -92,6 +93,43 @@ function formatBatchFailureSummary( .join("; "); } +// ── Collapsible section ───────────────────────────────────────────────────── + +function DisclosureSection({ + children, + defaultOpen = false, + label, + testId, +}: { + children: React.ReactNode; + defaultOpen?: boolean; + label: string; + testId?: string; +}) { + const [isOpen, setIsOpen] = React.useState(defaultOpen); + + return ( +
+ + {isOpen ?
{children}
: null} +
+ ); +} + +// ── Main dialog ───────────────────────────────────────────────────────────── + export function AddChannelBotDialog({ backendProviders, backendProvidersLoading, @@ -418,9 +456,6 @@ export function AddChannelBotDialog({ } } - // Allowlist mode requires at least one entry, mirroring the harness's own - // validation. If we let it through empty, the agent crash-loops at startup - // with a config error. const respondToValid = respondTo !== "allowlist" || respondToAllowlist.length > 0; @@ -453,7 +488,7 @@ export function AddChannelBotDialog({ + ))} + + ) : ( + + 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 ( + ))} ) : ( @@ -500,31 +535,24 @@ export function QuickAddAgentPopover({ )} - {/* Right side: Select toggle or Add (N) button */} -
- {multiSelectActive ? ( - - ) : usableTeams.length > 0 ? ( + {/* Right side: Select / Cancel toggle */} + {usableTeams.length > 0 ? ( +
- ) : null} -
+
+ ) : null} {/* Scrollable content — clips mid-item to hint at more */} @@ -627,6 +655,24 @@ export function QuickAddAgentPopover({ More options… + + {multiSelectActive ? ( +
+ +
+ ) : null} From 85da5ddee15ac0349ea422917694c6c8a866ed24 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 21:37:26 -0700 Subject: [PATCH 16/62] Fix: stay in select mode when deselecting agents, stagger toggle animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove auto-exit from select mode when selectedKeys empties — only Cancel button exits select mode now. - Stagger team Toggle animations: first chip fades in immediately, subsequent chips delay by index * 50ms for a fan-out effect. --- .../channels/ui/QuickAddAgentPopover.tsx | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 2fc08b003..f763a958c 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -311,9 +311,6 @@ export function QuickAddAgentPopover({ } else { next.add(key); } - if (next.size === 0) { - setSelectMode(false); - } return next; }); } @@ -501,24 +498,34 @@ export function QuickAddAgentPopover({ - {usableTeams.map((team) => ( - ( + - handleTeamToggle(team, pressed) - } - pressed={selectedTeamIds.has(team.id)} - size="sm" - variant="outline" + initial={{ opacity: 0, x: 12 }} + animate={{ opacity: 1, x: 0 }} + transition={{ + duration: 0.2, + delay: index * 0.05, + }} + className="shrink-0" > - {team.name} - + + handleTeamToggle(team, pressed) + } + pressed={selectedTeamIds.has(team.id)} + size="sm" + variant="outline" + > + {team.name} + + ))} ) : ( From a3b425db4ed93b989a14ca632403719045c55127 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 21:40:56 -0700 Subject: [PATCH 17/62] =?UTF-8?q?Polish:=20secondary=E2=86=92primary=20Sel?= =?UTF-8?q?ect=20button,=20animated=20checkboxes,=20truncation=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Select button uses Button variant='secondary', switches to 'default' (primary) when active for clear visual state change - Checkboxes animate in with fade + scale (motion.div) when entering select mode - Agent name uses min-w-0 + truncate for proper text overflow when checkbox pushes content over --- .../channels/ui/QuickAddAgentPopover.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index f763a958c..894331d20 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -542,11 +542,10 @@ export function QuickAddAgentPopover({ )} - {/* Right side: Select / Cancel toggle */} + {/* Right side: Select / Cancel button */} {usableTeams.length > 0 ? (
- +
) : null} @@ -605,23 +606,26 @@ export function QuickAddAgentPopover({ type="button" > {selectMode && !isInChannel ? ( -
{isSelected ? : null} -
+ ) : null} - + {item.label} {isInChannel ? ( From f54727e57b4ba8b6ef7da6f643ab6e453923c903 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 21:48:00 -0700 Subject: [PATCH 18/62] Fix Select button: keep label as 'Select', use outline variant for visibility - Label stays 'Select' in both states (no more 'Cancel' label) - Inactive: variant='outline' (visible against bg-popover) - Active: variant='default' (primary) for clear state change - Popover uses bg-popover token (hsl 220 23% 95% light / 232 23% 18% dark) --- desktop/src/features/channels/ui/QuickAddAgentPopover.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 894331d20..4bd0626a6 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -555,9 +555,9 @@ export function QuickAddAgentPopover({ }} size="sm" type="button" - variant={selectMode ? "default" : "secondary"} + variant={selectMode ? "default" : "outline"} > - {selectMode ? "Cancel" : "Select"} + Select ) : null} From ff513e9cdcc3d4ef39cfe78958e7c94402396e6d Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 21:54:51 -0700 Subject: [PATCH 19/62] Fix Select button: ghost+border for transparent bg, no bg-background clash The outline variant includes bg-background which creates a visible contrast block against bg-popover. Use ghost variant with explicit border-input border for a truly transparent-bg outlined button. --- desktop/src/features/channels/ui/QuickAddAgentPopover.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 4bd0626a6..19c2a21cf 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -546,6 +546,11 @@ export function QuickAddAgentPopover({ {usableTeams.length > 0 ? (
From dcf0655330b7cdee53dd6dcf3224c223aa12d69e Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 21:59:50 -0700 Subject: [PATCH 20/62] Remove agent checkboxes from select mode, keep it clean Checkboxes were too busy in the agent list. Selection is now indicated only by the bg-accent/50 highlight on selected rows. Button already uses size=sm. --- .../features/channels/ui/QuickAddAgentPopover.tsx | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 19c2a21cf..98e1c6590 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -610,21 +610,6 @@ export function QuickAddAgentPopover({ tabIndex={isInChannel ? -1 : 0} type="button" > - {selectMode && !isInChannel ? ( - - {isSelected ? : null} - - ) : null} Date: Wed, 20 May 2026 22:02:08 -0700 Subject: [PATCH 21/62] =?UTF-8?q?Restore=20checkbox=20without=20animation?= =?UTF-8?q?=20=E2=80=94=20static=20div,=20no=20motion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/channels/ui/QuickAddAgentPopover.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 98e1c6590..c333c47de 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -610,6 +610,18 @@ export function QuickAddAgentPopover({ tabIndex={isInChannel ? -1 : 0} type="button" > + {selectMode && !isInChannel ? ( +
+ {isSelected ? : null} +
+ ) : null} Date: Wed, 20 May 2026 22:06:16 -0700 Subject: [PATCH 22/62] Polish: first team fades only, 2nd+ translate+fade; fix header padding - First team chip: opacity-only animation (no x translate) - Second+ chips: translate from x:12 + fade, staggered by index - Header container: px-3 py-1.5 with gap-2 for consistent spacing around the Select button on all sides --- desktop/src/features/channels/ui/QuickAddAgentPopover.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index c333c47de..7f0cdf16a 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -492,7 +492,7 @@ export function QuickAddAgentPopover({ }} > {/* Header with animated title / team toggles */} -
+
{selectMode ? ( ( 0 ? ( -
+
From df7838496c04eb64f146181060921a553fc5f5bd Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 22:21:22 -0700 Subject: [PATCH 27/62] Rethink: Select button always outline, label toggles Select/Cancel, default toggle colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Button stays ghost+border (outline look) in both states - Label changes: 'Select' → 'Cancel' when active - Toggle chips use default data-[state=on]:bg-primary (removed accent override) --- .../features/channels/ui/QuickAddAgentPopover.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 2f8a7e106..ee192e608 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -515,7 +515,7 @@ export function QuickAddAgentPopover({ className="shrink-0" > handleTeamToggle(team, pressed) } @@ -546,11 +546,7 @@ export function QuickAddAgentPopover({ {usableTeams.length > 0 ? (
) : null} From b682fb70e282591a4220f68c32a47d67d23cc538 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 22:25:46 -0700 Subject: [PATCH 28/62] Keep title visible, toggles to the right, gradient scroll affordance - 'Add agent' title stays visible always; 's' fades in when select mode activates ('Add agents') - Team toggles appear to the right of the title, scroll horizontally - Right-edge gradient (from-popover to-transparent) hints at more content when overflowing --- .../channels/ui/QuickAddAgentPopover.tsx | 86 +++++++++++-------- 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index ee192e608..cd4836e6a 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -493,53 +493,65 @@ export function QuickAddAgentPopover({ > {/* Header with animated title / team toggles */}
- + {/* Title — always visible */} +

+ Add agent + + {selectMode ? ( + + s + + ) : null} + +

+ + {/* Team toggles — appear to the right of title */} + {selectMode ? ( - {usableTeams.map((team, index) => ( - - - handleTeamToggle(team, pressed) - } - pressed={selectedTeamIds.has(team.id)} - size="sm" - variant="outline" +
+ {usableTeams.map((team, index) => ( + - {team.name} - - - ))} + + handleTeamToggle(team, pressed) + } + pressed={selectedTeamIds.has(team.id)} + size="sm" + variant="outline" + > + {team.name} + + + ))} +
+ {/* Gradient fade on right edge — scroll affordance */} +
- ) : ( - - Add agent - - )} + ) : null} {/* Right side: Select / Cancel button */} From f15c436cd2e93e80a29ff7842a5854c7d14e341b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 22:26:37 -0700 Subject: [PATCH 29/62] Fix 's' animation: opacity only, no width transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The width:0→auto animation caused the 's' to render broken. Just fade it in with opacity — it's one character, doesn't need a width transition. --- .../channels/ui/QuickAddAgentPopover.tsx | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index cd4836e6a..bbb4f84ee 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -495,20 +495,16 @@ export function QuickAddAgentPopover({
{/* Title — always visible */}

- Add agent - - {selectMode ? ( - - s - - ) : null} - + Add agent{selectMode ? ( + + s + + ) : null}

{/* Team toggles — appear to the right of title */} From afa4abd3dd14e5550404f2c8c0d4254fa7979099 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 22:32:00 -0700 Subject: [PATCH 30/62] Move team toggles to own row between header and list with layout animation - Toggles sit in a separate row that slides in/out (height animation) - Agent list uses motion layout to smoothly translate down when row appears - Header stays clean: just title + Select/Cancel button - Gradient scroll affordance on the toggle row's right edge --- .../channels/ui/QuickAddAgentPopover.tsx | 103 +++++++++--------- 1 file changed, 54 insertions(+), 49 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index bbb4f84ee..62d1ffeec 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -492,10 +492,11 @@ export function QuickAddAgentPopover({ }} > {/* Header with animated title / team toggles */} -
- {/* Title — always visible */} + {/* Header */} +

- Add agent{selectMode ? ( + Add agent + {selectMode ? ( - {/* Team toggles — appear to the right of title */} - - {selectMode ? ( - -
- {usableTeams.map((team, index) => ( - - - handleTeamToggle(team, pressed) - } - pressed={selectedTeamIds.has(team.id)} - size="sm" - variant="outline" - > - {team.name} - - - ))} -
- {/* Gradient fade on right edge — scroll affordance */} -
- - ) : null} - - - {/* Right side: Select / Cancel button */} {usableTeams.length > 0 ? (
+ ); })}
@@ -678,23 +683,32 @@ export function QuickAddAgentPopover({
- {multiSelectActive ? ( -
- -
- ) : null} + +
+ ) : null} +

diff --git a/desktop/src/shared/ui/toggle.tsx b/desktop/src/shared/ui/toggle.tsx index fa51ac45e..aec80a8c8 100644 --- a/desktop/src/shared/ui/toggle.tsx +++ b/desktop/src/shared/ui/toggle.tsx @@ -13,7 +13,7 @@ const toggleVariants = cva( 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-2 data-[state=on]:border-primary data-[state=on]:!text-foreground", + "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", From 8e6608c1749550aca9e2bf3adac6b2a5b6541873 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 23:08:49 -0700 Subject: [PATCH 36/62] =?UTF-8?q?fix:=20checkbox=20animates=20width=20cont?= =?UTF-8?q?inuously=20instead=20of=20mount/unmount=20=E2=80=94=20no=20more?= =?UTF-8?q?=20jank=20on=20exit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channels/ui/QuickAddAgentPopover.tsx | 62 ++++++++----------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 828458478..9d761bae2 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -591,8 +591,7 @@ export function QuickAddAgentPopover({ const isSelected = selectedKeys.has(itemKey); return ( - handleItemClick(item)} role="option" tabIndex={isInChannel ? -1 : 0} - transition={{ duration: 0.15 }} type="button" > - - {selectMode && !isInChannel ? ( - +
-
- {isSelected ? : null} -
- - ) : null} - - - - - + {isSelected ? : null} +
+
+ ) : null} + + {item.label} - + {isInChannel ? ( ) : item.kind === "running-available" && !selectMode ? ( @@ -654,7 +646,7 @@ export function QuickAddAgentPopover({ {isItemPending ? ( ) : null} -
+ ); })}
From 6481789bd30feece6948ed982ed6e5bc1b42e6b4 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 23:10:56 -0700 Subject: [PATCH 37/62] =?UTF-8?q?fix:=20team=20filter=20row=20always=20mou?= =?UTF-8?q?nted=20=E2=80=94=20animate=20height=20continuously,=20no=20moun?= =?UTF-8?q?t/unmount=20jank?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channels/ui/QuickAddAgentPopover.tsx | 85 ++++++++++--------- 1 file changed, 43 insertions(+), 42 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 9d761bae2..abed16427 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -519,49 +519,50 @@ export function QuickAddAgentPopover({ ) : null}
- {/* Team toggles row — slides in between header and list */} - - {selectMode ? ( - -
- {usableTeams.map((team, index) => ( - +
+
+ {usableTeams.map((team, index) => ( + + + handleTeamToggle(team, pressed) + } + pressed={selectedTeamIds.has(team.id)} + size="sm" + variant="subtle" > - - handleTeamToggle(team, pressed) - } - pressed={selectedTeamIds.has(team.id)} - size="sm" - variant="subtle" - > - {team.name} - - - ))} -
- {/* Gradient fade on right edge — scroll affordance */} -
- - ) : null} - + {team.name} + + + ))} +
+ {/* Gradient fade on right edge — scroll affordance */} +
+
+ {/* Scrollable content — clips mid-item to hint at more */} Date: Wed, 20 May 2026 23:15:55 -0700 Subject: [PATCH 38/62] =?UTF-8?q?fix:=20remove=20gap-2.5=20from=20row,=20a?= =?UTF-8?q?nimate=20marginRight=20on=20checkbox,=20wrap=20content=20in=20g?= =?UTF-8?q?apped=20div=20=E2=80=94=20no=20extra=20left=20padding=20when=20?= =?UTF-8?q?checkbox=20hidden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channels/ui/QuickAddAgentPopover.tsx | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index abed16427..d0e9036e4 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -595,7 +595,7 @@ export function QuickAddAgentPopover({ ); })} From 16dc0094d0d58b1c5bc8b1acdd6119424780097c Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 23:17:42 -0700 Subject: [PATCH 39/62] fix: reduce bottom padding on floating Add button --- desktop/src/features/channels/ui/QuickAddAgentPopover.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index d0e9036e4..5f8642a91 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -682,7 +682,7 @@ export function QuickAddAgentPopover({ {multiSelectActive ? ( Date: Wed, 20 May 2026 23:19:37 -0700 Subject: [PATCH 40/62] =?UTF-8?q?fix:=20bump=20More=20options=20button=20p?= =?UTF-8?q?y-2=20=E2=86=92=20py-2.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/src/features/channels/ui/QuickAddAgentPopover.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 5f8642a91..54425dfc7 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -664,7 +664,7 @@ export function QuickAddAgentPopover({
); From cb848348d76c82ad72975a94ba9d589735ae9148 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 23:29:36 -0700 Subject: [PATCH 44/62] fix: remove running tag, use opacity-50 on inactive agent avatars instead --- desktop/src/features/channels/ui/QuickAddAgentPopover.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 6db213617..18948283a 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -640,10 +640,6 @@ export function QuickAddAgentPopover({ {isInChannel ? ( - ) : item.kind === "running-available" && !selectMode ? ( - - running - ) : null} {isItemPending ? ( @@ -729,7 +725,7 @@ function QuickAddAgentAvatar({ .toUpperCase(); return ( -
+
{avatarUrl ? ( {label} Date: Wed, 20 May 2026 23:31:17 -0700 Subject: [PATCH 45/62] =?UTF-8?q?fix:=20remove=20opacity-50=20on=20inactiv?= =?UTF-8?q?e=20avatars=20=E2=80=94=20looked=20disabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/src/features/channels/ui/QuickAddAgentPopover.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 18948283a..f8aae1b05 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -725,7 +725,7 @@ function QuickAddAgentAvatar({ .toUpperCase(); return ( -
+
{avatarUrl ? ( {label} Date: Wed, 20 May 2026 23:36:13 -0700 Subject: [PATCH 46/62] fix: deselect team toggle when individual agents are unchecked --- .../channels/ui/QuickAddAgentPopover.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index f8aae1b05..ed4cd3650 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -311,6 +311,34 @@ export function QuickAddAgentPopover({ } else { next.add(key); } + + // Deselect any team whose members are no longer all selected + 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((persona) => { + const runningItem = items.find( + (i) => i.kind === "running-available" && i.agent.personaId === persona.id, + ); + const itemKey = runningItem + ? getItemKey(runningItem) + : (() => { + const personaItem = items.find( + (i) => i.kind === "persona" && i.persona.id === persona.id, + ); + return personaItem ? getItemKey(personaItem) : null; + })(); + return itemKey ? next.has(itemKey) : true; + }); + if (!allSelected) { + nextTeams.delete(team.id); + } + } + return nextTeams; + }); + return next; }); } From d8e29de06b93f1bf1f9f08d6a51f824d50325b1f Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 23:47:20 -0700 Subject: [PATCH 47/62] revert: restore AddChannelBotDialog to pre-branch state (remove progressive disclosure changes) --- .../channels/ui/AddChannelBotDialog.tsx | 255 +++++++----------- 1 file changed, 101 insertions(+), 154 deletions(-) diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx index 231e0973b..45c33d59e 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; +import { AlertTriangle, ChevronDown } from "lucide-react"; import * as React from "react"; import { @@ -43,7 +43,6 @@ import { getActivePersonas } from "@/features/agents/lib/catalog"; import { getUsableTeams } from "@/features/agents/lib/teamPersonas"; import { useLastRuntimeProvider } from "@/features/agents/lib/useLastRuntimeProvider"; import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField"; -import { cn } from "@/shared/lib/cn"; type AddChannelBotDialogProps = { backendProviders?: BackendProviderCandidate[]; @@ -93,43 +92,6 @@ function formatBatchFailureSummary( .join("; "); } -// ── Collapsible section ───────────────────────────────────────────────────── - -function DisclosureSection({ - children, - defaultOpen = false, - label, - testId, -}: { - children: React.ReactNode; - defaultOpen?: boolean; - label: string; - testId?: string; -}) { - const [isOpen, setIsOpen] = React.useState(defaultOpen); - - return ( -
- - {isOpen ?
{children}
: null} -
- ); -} - -// ── Main dialog ───────────────────────────────────────────────────────────── - export function AddChannelBotDialog({ backendProviders, backendProvidersLoading, @@ -456,6 +418,9 @@ export function AddChannelBotDialog({ } } + // Allowlist mode requires at least one entry, mirroring the harness's own + // validation. If we let it through empty, the agent crash-loops at startup + // with a config error. const respondToValid = respondTo !== "allowlist" || respondToAllowlist.length > 0; @@ -488,7 +453,7 @@ export function AddChannelBotDialog({ ) : null} {isItemPending ? ( From 90fa35ec0441a5d3046d8b5a4ff15aec598a4aa3 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 21 May 2026 00:01:42 -0700 Subject: [PATCH 50/62] fix: opacity-70 on in-channel agent avatar + label, X stays full opacity --- .../features/channels/ui/QuickAddAgentPopover.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 1a02e4321..acece9fba 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -659,12 +659,14 @@ export function QuickAddAgentPopover({ ) : null}
- - + + + + {item.label} {isInChannel ? ( From 79f59d45ac6a8179333ad2e38cd496c7fb94fefe Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 21 May 2026 00:02:41 -0700 Subject: [PATCH 51/62] =?UTF-8?q?fix:=20opacity-70=20=E2=86=92=20opacity-5?= =?UTF-8?q?0=20on=20in-channel=20agent=20avatar=20+=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/src/features/channels/ui/QuickAddAgentPopover.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index acece9fba..70d802f77 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -659,14 +659,14 @@ export function QuickAddAgentPopover({ ) : null}
- + - + {item.label} {isInChannel ? ( From abdb683638de41d88532ee112bf605c87994580e Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 21 May 2026 00:06:38 -0700 Subject: [PATCH 52/62] fix: popover stays open after add/remove, items don't reorder while open --- .../channels/ui/QuickAddAgentPopover.tsx | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 70d802f77..958e43129 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -225,6 +225,39 @@ export function QuickAddAgentPopover({ return result; }, [managedAgents, personas, channelMemberPubkeys, recentIds]); + // Snapshot item order when popover opens — don't reorder while open + const [stableItems, setStableItems] = React.useState([]); + React.useEffect(() => { + if (open) { + setStableItems(items); + } + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + // Merge live data (kind changes) into stable order while keeping positions + const displayItems = React.useMemo(() => { + if (!open) return items; + const liveMap = new Map(items.map((item) => [getItemKey(item), item])); + // Keep stable order, update each item with live data, add new items at end + const result: QuickAddAgentItem[] = []; + const seen = new Set(); + for (const stableItem of stableItems) { + const key = getItemKey(stableItem); + const liveItem = liveMap.get(key); + if (liveItem) { + result.push(liveItem); + seen.add(key); + } + } + // Append any new items that weren't in the snapshot + for (const item of items) { + const key = getItemKey(item); + if (!seen.has(key)) { + result.push(item); + } + } + return result; + }, [open, stableItems, items]); + // Reset state when popover closes React.useEffect(() => { if (!open) { @@ -247,7 +280,7 @@ export function QuickAddAgentPopover({ try { await attachMutation.mutateAsync({ agent, ensureRunning: true }); if (agent.personaId) pushRecent(agent.personaId); - onOpenChange(false); + setPendingKey(null); } catch (err) { setErrorMessage( err instanceof Error ? err.message : "Failed to add agent.", @@ -287,7 +320,7 @@ export function QuickAddAgentPopover({ model: persona.model ?? undefined, }); pushRecent(persona.id); - onOpenChange(false); + setPendingKey(null); } catch (err) { setErrorMessage( err instanceof Error ? err.message : "Failed to add agent.", @@ -447,7 +480,10 @@ export function QuickAddAgentPopover({ } } - onOpenChange(false); + setPendingKey(null); + setSelectMode(false); + setSelectedKeys(new Set()); + setSelectedTeamIds(new Set()); } catch (err) { setErrorMessage( err instanceof Error ? err.message : "Failed to add agents.", @@ -603,7 +639,7 @@ export function QuickAddAgentPopover({
- ) : items.length === 0 ? ( + ) : displayItems.length === 0 ? (
No agents available.
@@ -613,7 +649,7 @@ export function QuickAddAgentPopover({ className="py-1" role="listbox" > - {items.map((item) => { + {displayItems.map((item) => { const itemKey = getItemKey(item); const isInChannel = item.kind === "running-in-channel"; const isItemPending = From 7038c4cbf9125f30a2a4871b9ded4e878b74ea7c Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 21 May 2026 00:08:37 -0700 Subject: [PATCH 53/62] fix: prepend newly created agents to top of list instead of appending --- .../features/channels/ui/QuickAddAgentPopover.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 958e43129..0b06c3f89 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -237,25 +237,26 @@ export function QuickAddAgentPopover({ const displayItems = React.useMemo(() => { if (!open) return items; const liveMap = new Map(items.map((item) => [getItemKey(item), item])); - // Keep stable order, update each item with live data, add new items at end - const result: QuickAddAgentItem[] = []; + // Keep stable order, update each item with live data + const stable: QuickAddAgentItem[] = []; const seen = new Set(); for (const stableItem of stableItems) { const key = getItemKey(stableItem); const liveItem = liveMap.get(key); if (liveItem) { - result.push(liveItem); + stable.push(liveItem); seen.add(key); } } - // Append any new items that weren't in the snapshot + // Prepend any new items (e.g. newly created agents) at the top + const newItems: QuickAddAgentItem[] = []; for (const item of items) { const key = getItemKey(item); if (!seen.has(key)) { - result.push(item); + newItems.push(item); } } - return result; + return [...newItems, ...stable]; }, [open, stableItems, items]); // Reset state when popover closes From 4944671516b908b4b85beac819501d85b7f1efcb Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 21 May 2026 00:11:17 -0700 Subject: [PATCH 54/62] =?UTF-8?q?fix:=20use=20ref-based=20frozen=20key=20o?= =?UTF-8?q?rder=20=E2=80=94=20bulletproof=20stable=20item=20positions=20wh?= =?UTF-8?q?ile=20popover=20is=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channels/ui/QuickAddAgentPopover.tsx | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 0b06c3f89..799d72d55 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -225,39 +225,44 @@ export function QuickAddAgentPopover({ return result; }, [managedAgents, personas, channelMemberPubkeys, recentIds]); - // Snapshot item order when popover opens — don't reorder while open - const [stableItems, setStableItems] = React.useState([]); - React.useEffect(() => { - if (open) { - setStableItems(items); - } - }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + // Frozen key order — captured once when popover opens, used for rendering + const frozenKeysRef = React.useRef([]); + const prevOpenRef = React.useRef(false); + if (open && !prevOpenRef.current) { + // Popover just opened — freeze the current key order + frozenKeysRef.current = items.map(getItemKey); + } + prevOpenRef.current = open; - // Merge live data (kind changes) into stable order while keeping positions + // Render items in frozen order, prepend any new ones const displayItems = React.useMemo(() => { if (!open) return items; + const frozenKeys = frozenKeysRef.current; const liveMap = new Map(items.map((item) => [getItemKey(item), item])); - // Keep stable order, update each item with live data - const stable: QuickAddAgentItem[] = []; - const seen = new Set(); - for (const stableItem of stableItems) { - const key = getItemKey(stableItem); + + // Existing items in frozen order (with live data) + const ordered: QuickAddAgentItem[] = []; + for (const key of frozenKeys) { const liveItem = liveMap.get(key); if (liveItem) { - stable.push(liveItem); - seen.add(key); + ordered.push(liveItem); } } - // Prepend any new items (e.g. newly created agents) at the top + + // New items (not in frozen snapshot) prepend to top + const frozenSet = new Set(frozenKeys); const newItems: QuickAddAgentItem[] = []; for (const item of items) { const key = getItemKey(item); - if (!seen.has(key)) { + if (!frozenSet.has(key)) { newItems.push(item); + // Add to frozen keys so they stay in position on subsequent renders + frozenKeysRef.current = [key, ...frozenKeysRef.current]; } } - return [...newItems, ...stable]; - }, [open, stableItems, items]); + + return [...newItems, ...ordered]; + }, [open, items]); // Reset state when popover closes React.useEffect(() => { From ac7727d531bf7c1a5ccfce477c1aabc11cb2502a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 21 May 2026 00:15:07 -0700 Subject: [PATCH 55/62] fix: remove frozen-order code, use layout animation for reorder, in-channel agents sort to top --- .../channels/ui/QuickAddAgentPopover.tsx | 53 +++---------------- 1 file changed, 8 insertions(+), 45 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 799d72d55..882dd759b 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -178,12 +178,12 @@ export function QuickAddAgentPopover({ return aScore - bScore; }); - for (const agent of sortedRunningAvailable) { + for (const agent of runningInChannel) { const persona = agent.personaId ? (personas.find((p) => p.id === agent.personaId) ?? null) : null; result.push({ - kind: "running-available", + kind: "running-in-channel", agent, persona, label: agent.name, @@ -191,12 +191,12 @@ export function QuickAddAgentPopover({ }); } - for (const agent of runningInChannel) { + for (const agent of sortedRunningAvailable) { const persona = agent.personaId ? (personas.find((p) => p.id === agent.personaId) ?? null) : null; result.push({ - kind: "running-in-channel", + kind: "running-available", agent, persona, label: agent.name, @@ -225,44 +225,6 @@ export function QuickAddAgentPopover({ return result; }, [managedAgents, personas, channelMemberPubkeys, recentIds]); - // Frozen key order — captured once when popover opens, used for rendering - const frozenKeysRef = React.useRef([]); - const prevOpenRef = React.useRef(false); - if (open && !prevOpenRef.current) { - // Popover just opened — freeze the current key order - frozenKeysRef.current = items.map(getItemKey); - } - prevOpenRef.current = open; - - // Render items in frozen order, prepend any new ones - const displayItems = React.useMemo(() => { - if (!open) return items; - const frozenKeys = frozenKeysRef.current; - const liveMap = new Map(items.map((item) => [getItemKey(item), item])); - - // Existing items in frozen order (with live data) - const ordered: QuickAddAgentItem[] = []; - for (const key of frozenKeys) { - const liveItem = liveMap.get(key); - if (liveItem) { - ordered.push(liveItem); - } - } - - // New items (not in frozen snapshot) prepend to top - const frozenSet = new Set(frozenKeys); - const newItems: QuickAddAgentItem[] = []; - for (const item of items) { - const key = getItemKey(item); - if (!frozenSet.has(key)) { - newItems.push(item); - // Add to frozen keys so they stay in position on subsequent renders - frozenKeysRef.current = [key, ...frozenKeysRef.current]; - } - } - - return [...newItems, ...ordered]; - }, [open, items]); // Reset state when popover closes React.useEffect(() => { @@ -645,7 +607,7 @@ export function QuickAddAgentPopover({
- ) : displayItems.length === 0 ? ( + ) : items.length === 0 ? (
No agents available.
@@ -655,7 +617,7 @@ export function QuickAddAgentPopover({ className="py-1" role="listbox" > - {displayItems.map((item) => { + {items.map((item) => { const itemKey = getItemKey(item); const isInChannel = item.kind === "running-in-channel"; const isItemPending = @@ -663,6 +625,7 @@ export function QuickAddAgentPopover({ const isSelected = selectedKeys.has(itemKey); return ( +
+ ); })}
From 2f378cb7c3fb523ced372c2bc68938d0fd0c01de Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 21 May 2026 00:18:34 -0700 Subject: [PATCH 56/62] fix: debounce item list (300ms) so name + membership changes batch into one animation --- .../features/channels/ui/QuickAddAgentPopover.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 882dd759b..8c06139c4 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -225,6 +225,13 @@ export function QuickAddAgentPopover({ return result; }, [managedAgents, personas, channelMemberPubkeys, recentIds]); + // Debounce item list so rapid query invalidations (name + membership) batch + const [debouncedItems, setDebouncedItems] = React.useState(items); + React.useEffect(() => { + const timer = setTimeout(() => setDebouncedItems(items), 300); + return () => clearTimeout(timer); + }, [items]); + // Reset state when popover closes React.useEffect(() => { @@ -607,7 +614,7 @@ export function QuickAddAgentPopover({
- ) : items.length === 0 ? ( + ) : debouncedItems.length === 0 ? (
No agents available.
@@ -617,7 +624,7 @@ export function QuickAddAgentPopover({ className="py-1" role="listbox" > - {items.map((item) => { + {debouncedItems.map((item) => { const itemKey = getItemKey(item); const isInChannel = item.kind === "running-in-channel"; const isItemPending = @@ -625,7 +632,7 @@ export function QuickAddAgentPopover({ const isSelected = selectedKeys.has(itemKey); return ( - + + ) : null} + {isPending ? ( + + ) : null} +
+ + + ); +}); diff --git a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx index 882dd759b..99d37933e 100644 --- a/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx +++ b/desktop/src/features/channels/ui/QuickAddAgentPopover.tsx @@ -1,63 +1,16 @@ -import { Check, Settings2, X } from "lucide-react"; +import { 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 { Toggle } from "@/shared/ui/toggle"; -import { useChannelMembersQuery, useRemoveChannelMemberMutation } 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 { - 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"; +import { getItemKey, useQuickAddAgentItems } from "./useQuickAddAgentItems"; +import { useQuickAddAgentActions } from "./useQuickAddAgentActions"; +import { QuickAddAgentItemRow } from "./QuickAddAgentItemRow"; -// ── 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; +// ── Component ───────────────────────────────────────────────────────────────── type QuickAddAgentPopoverProps = { channelId: string | null; @@ -67,27 +20,6 @@ type QuickAddAgentPopoverProps = { 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); - if (name && name.trim().length > 0) return name; - return persona.displayName || "Agent"; -} - -// ── Component ───────────────────────────────────────────────────────────────── - export function QuickAddAgentPopover({ channelId, open, @@ -95,393 +27,45 @@ export function QuickAddAgentPopover({ onMoreOptions, children, }: QuickAddAgentPopoverProps) { - const managedAgentsQuery = useManagedAgentsQuery(); - const personasQuery = usePersonasQuery(); - const providersQuery = useAcpProvidersQuery(); - const teamsQuery = useTeamsQuery(); - const membersQuery = useChannelMembersQuery( + 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, - open && channelId !== null, - ); - const attachMutation = useAttachManagedAgentToChannelMutation(channelId); - const createMutation = useCreateChannelManagedAgentMutation(channelId); - const batchCreateMutation = useCreateChannelManagedAgentsMutation(channelId); - const removeMutation = useRemoveChannelMemberMutation(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 [selectedTeamIds, setSelectedTeamIds] = React.useState>( - new Set(), - ); - - 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( - () => getUsableTeams(teams, personas), - [teams, personas], - ); - - // Build the sorted item list - 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]); - + items, + managedAgents, + personas, + providers, + defaultProvider, + usableTeams, + pushRecent, + }); // Reset state when popover closes React.useEffect(() => { - if (!open) { - setPendingKey(null); - setErrorMessage(null); - setSelectMode(false); - setSelectedKeys(new Set()); - setSelectedTeamIds(new Set()); - } - }, [open]); - - // ── 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); - } - - // Deselect any team whose members are no longer all selected - 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((persona) => { - const runningItem = items.find( - (i) => i.kind === "running-available" && i.agent.personaId === persona.id, - ); - const itemKey = runningItem - ? getItemKey(runningItem) - : (() => { - const personaItem = items.find( - (i) => i.kind === "persona" && i.persona.id === persona.id, - ); - return personaItem ? getItemKey(personaItem) : null; - })(); - return itemKey ? next.has(itemKey) : 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); - if (pressed) { - for (const key of memberKeys) { - next.add(key); - } - } else { - for (const key of memberKeys) { - 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); - } - } - } - - const isLoading = - managedAgentsQuery.isLoading || - personasQuery.isLoading || - providersQuery.isLoading; + if (!open) reset(); + }, [open, reset]); const multiSelectActive = selectMode && selectedKeys.size > 0; @@ -497,9 +81,11 @@ export function QuickAddAgentPopover({ className="w-72 overflow-hidden p-0" sideOffset={6} > + {/* biome-ignore lint/a11y/useSemanticElements: composite widget with roving focus */}
{ const container = e.currentTarget; const buttons = Array.from( @@ -524,23 +110,18 @@ export function QuickAddAgentPopover({ } }} > - {/* Header with animated title / team toggles */} {/* Header */}

Add agent

- {usableTeams.length > 0 ? (
- {/* Team toggles row — always mounted, height animates based on selectMode */} + {/* Team toggles row */} ))}
- {/* Gradient fade on right edge — scroll affordance */}
- {/* Scrollable content — clips mid-item to hint at more */} + {/* Scrollable content */} {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 ( - - - ) : null} - {isItemPending ? ( - - ) : null} -
- - + onRemove={(pubkey) => removeMutation.mutate(pubkey)} + /> ); })}
@@ -751,42 +272,3 @@ export function QuickAddAgentPopover({ ); } - -// ── 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/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, + }; +} From c65fd6e2b07de4843a692d0336bdd1be45449761 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 21 May 2026 19:54:51 -0700 Subject: [PATCH 59/62] =?UTF-8?q?feat:=20optimistic=20remove=20=E2=80=94?= =?UTF-8?q?=20agent=20vanishes=20instantly=20from=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the attach optimistic update. onMutate filters the member out of the cache immediately, rolls back on error, invalidates on settle to reconcile with server. --- desktop/src/features/channels/hooks.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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), From 4bb06b8aedd25cc35384b6acde9b5f023b18d310 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 21 May 2026 22:34:35 -0700 Subject: [PATCH 60/62] feat: sort sidebar bots online-first, then alphabetical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a sortedBots memo in MembersSidebar that prioritizes online agents at the top. Within each presence group (online vs not-online), agents sort alphabetically by display name. No more recency-based reordering — the list stays stable. --- .../features/channels/ui/MembersSidebar.tsx | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 22c052620..19563834c 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -96,6 +96,17 @@ 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 = @@ -122,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) => { @@ -141,7 +152,7 @@ export function MembersSidebar({ ); const removableManagedBots = React.useMemo( () => - bots.flatMap((member) => { + sortedBots.flatMap((member) => { if (!canRemoveMember(member)) { return []; } @@ -149,7 +160,7 @@ export function MembersSidebar({ const agent = managedAgentByPubkey.get(normalizePubkey(member.pubkey)); return agent ? [agent] : []; }), - [bots, canRemoveMember, managedAgentByPubkey], + [sortedBots, canRemoveMember, managedAgentByPubkey], ); const { actionErrorMessage, @@ -279,7 +290,7 @@ export function MembersSidebar({

Bots

- {bots.length} + {sortedBots.length}
{canAddAgents ? ( @@ -322,8 +333,8 @@ export function MembersSidebar({
- {bots.length > 0 ? ( - bots.map((member) => renderMemberCard(member, true)) + {sortedBots.length > 0 ? ( + sortedBots.map((member) => renderMemberCard(member, true)) ) : (

{membersQuery.isLoading From 0b72f1a72f036380866aa30489a2eb9b2a38354b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 21 May 2026 23:06:58 -0700 Subject: [PATCH 61/62] fix: pass context to single-add mutation so reuse guard fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-agent create path was calling createChannelManagedAgent without context, so the reuse guard (findReusablePersonaAgent) never fired — always spawning a new keypair + process. Now fetches managedAgents + channelMemberPubkeys before calling create, matching the batch path's behavior. --- desktop/src/features/agents/hooks.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index b3500b55d..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, @@ -417,7 +418,18 @@ 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; From 8cf10d3e3c7dc3b89a7bd2bf5c7acfc373fd3116 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 22 May 2026 00:15:48 -0700 Subject: [PATCH 62/62] chore: bump file size limits for hooks with optimistic updates --- desktop/scripts/check-file-sizes.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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