diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 17a7a6e90..3f8eb2772 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -45,6 +45,8 @@ pub struct RelayAgentInfo { pub name: String, pub agent_type: String, pub channels: Vec, + #[serde(default)] + pub channel_ids: Vec, pub capabilities: Vec, pub status: String, } diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts new file mode 100644 index 000000000..7579912fe --- /dev/null +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -0,0 +1,197 @@ +import { sendChannelMessage } from "@/shared/api/tauri"; +import type { + Channel, + ManagedAgent, + PresenceLookup, + RelayAgent, +} from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +type DeleteManagedAgentInput = { + pubkey: string; + forceRemoteDelete?: boolean; +}; + +type StartManagedAgent = (pubkey: string) => Promise; +type StopManagedAgent = (pubkey: string) => Promise; +type DeleteManagedAgent = (input: DeleteManagedAgentInput) => Promise; + +type ManagedAgentChannelContext = { + channels: readonly Channel[]; + preferredChannelId?: string | null; + relayAgents: readonly RelayAgent[]; +}; + +type ManagedAgentActionContext = ManagedAgentChannelContext & { + presenceLookup?: PresenceLookup | null; +}; + +export type ManagedAgentActionResult = { + cancelled?: boolean; + noticeMessage?: string; +}; + +export function isManagedAgentActive(agent: Pick) { + return agent.status === "running" || agent.status === "deployed"; +} + +export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { + if (agent.backend.type === "provider") { + return isManagedAgentActive(agent) ? "Shutdown" : "Deploy"; + } + + if (isManagedAgentActive(agent)) { + return "Stop"; + } + + return agent.status === "stopped" ? "Respawn" : "Spawn"; +} + +export function resolveManagedAgentChannelId( + agent: Pick, + context: ManagedAgentChannelContext, +) { + if (context.preferredChannelId) { + return context.preferredChannelId; + } + + const relayAgent = context.relayAgents.find( + (candidate) => + normalizePubkey(candidate.pubkey) === normalizePubkey(agent.pubkey), + ); + + if (relayAgent?.channelIds?.length) { + return relayAgent.channelIds[0]; + } + + const channelName = relayAgent?.channels?.[0]; + if (!channelName) { + return null; + } + + const matches = context.channels.filter( + (channel) => channel.name === channelName, + ); + return matches.length === 1 ? matches[0].id : null; +} + +export async function startManagedAgentWithRules({ + agent, + startManagedAgent, +}: { + agent: ManagedAgent; + startManagedAgent: StartManagedAgent; +}) { + await startManagedAgent(agent.pubkey); +} + +export async function respawnManagedAgentWithRules({ + agent, + startManagedAgent, + stopManagedAgent, +}: { + agent: ManagedAgent; + startManagedAgent: StartManagedAgent; + stopManagedAgent: StopManagedAgent; +}) { + if (agent.backend.type === "local" && isManagedAgentActive(agent)) { + await stopManagedAgent(agent.pubkey); + } + + await startManagedAgent(agent.pubkey); +} + +export async function stopManagedAgentWithRules({ + agent, + channels, + preferredChannelId, + relayAgents, + stopManagedAgent, +}: { + agent: ManagedAgent; + stopManagedAgent: StopManagedAgent; +} & ManagedAgentChannelContext): Promise { + if (agent.backend.type === "provider") { + const channelId = resolveManagedAgentChannelId(agent, { + channels, + preferredChannelId, + relayAgents, + }); + if (!channelId) { + throw new Error("Cannot stop: agent is not in any channel"); + } + + await sendChannelMessage(channelId, "!shutdown", undefined, undefined, [ + agent.pubkey, + ]); + return { + noticeMessage: "Shutdown command sent. Agent will stop shortly.", + }; + } + + await stopManagedAgent(agent.pubkey); + return {}; +} + +export async function deleteManagedAgentWithRules({ + agent, + channels, + deleteManagedAgent, + preferredChannelId, + presenceLookup, + relayAgents, +}: { + agent: ManagedAgent; + deleteManagedAgent: DeleteManagedAgent; +} & ManagedAgentActionContext): Promise { + if (agent.backend.type === "provider" && agent.backendAgentId) { + const presence = presenceLookup?.[normalizePubkey(agent.pubkey)]; + const channelId = resolveManagedAgentChannelId(agent, { + channels, + preferredChannelId, + relayAgents, + }); + + if (channelId) { + if (presence === "online" || presence === "away") { + await sendChannelMessage(channelId, "!shutdown", undefined, undefined, [ + agent.pubkey, + ]); + + const confirmed = window.confirm( + "Shutdown command sent, but the agent may still be running. " + + "Deleting now removes the local record — the remote deployment " + + "will be orphaned if shutdown hasn't completed. Continue?", + ); + if (!confirmed) { + return { cancelled: true }; + } + } else { + const confirmed = window.confirm( + "This agent is offline but the remote deployment may still exist. " + + "Deleting removes the local management record. Continue?", + ); + if (!confirmed) { + return { cancelled: true }; + } + } + } else { + const confirmed = window.confirm( + "This agent is deployed but not in any channel. " + + "Deleting will orphan the remote deployment (it will keep running). Continue?", + ); + if (!confirmed) { + return { cancelled: true }; + } + } + } + + const isDeployedRemote = + agent.backend.type === "provider" && agent.backendAgentId; + await deleteManagedAgent({ + pubkey: agent.pubkey, + forceRemoteDelete: isDeployedRemote ? true : undefined, + }); + + return {}; +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index a33d81809..c31635e02 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -23,7 +23,6 @@ import { import { getPersonaLibraryState } from "@/features/agents/lib/catalog"; import { useChannelsQuery } from "@/features/channels/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; -import { sendChannelMessage } from "@/shared/api/tauri"; import { parsePersonaFiles, type ParsePersonaFilesResult, @@ -65,6 +64,11 @@ import { } from "./personaDialogState"; import { usePersonaImportActions } from "./usePersonaImportActions"; import { useTeamActions } from "./useTeamActions"; +import { + deleteManagedAgentWithRules, + startManagedAgentWithRules, + stopManagedAgentWithRules, +} from "../lib/managedAgentControlActions"; type PersonaFeedbackSurface = "catalog" | "library"; @@ -166,27 +170,6 @@ export function AgentsView() { ); const managedPresenceQuery = usePresenceQuery(managedPubkeyList); - /** Resolve a relay-agent's first channel UUID for sending !shutdown. */ - function resolveAgentChannelId(pubkey: string): string | null { - const relayAgents = relayAgentsQuery.data ?? []; - const relayAgent = relayAgents.find((ra) => ra.pubkey === pubkey); - // Prefer channelIds (new relay with json_agg). Fall back to resolving - // channel names via the channels query (old relay without channel_ids). - if (relayAgent?.channelIds?.length) { - return relayAgent.channelIds[0]; - } - // Fallback: resolve channel name → UUID via the channels query. - // Only use this when the match is unambiguous — if multiple channels - // share the same name (e.g. across teams), we can't be sure which one - // the agent is in, and sending !shutdown to the wrong channel would - // silently miss the agent. Return null to surface the error to the user. - const channelName = relayAgent?.channels?.[0]; - if (!channelName) return null; - const channels = channelsQuery.data ?? []; - const matches = channels.filter((ch) => ch.name === channelName); - return matches.length === 1 ? matches[0].id : null; - } - // Clear log selection if the agent was removed React.useEffect(() => { if ( @@ -214,7 +197,15 @@ export function AgentsView() { clearActionFeedback(); try { - await startMutation.mutateAsync(pubkey); + const agent = managedAgents.find( + (candidate) => candidate.pubkey === pubkey, + ); + if (!agent) return; + + await startManagedAgentWithRules({ + agent, + startManagedAgent: startMutation.mutateAsync, + }); } catch (error) { setActionErrorMessage( error instanceof Error ? error.message : "Failed to start agent.", @@ -229,22 +220,14 @@ export function AgentsView() { const agent = managedAgents.find((a) => a.pubkey === pubkey); if (!agent) return; - if (agent.backend.type === "provider") { - // Remote agent: send !shutdown mention via relay REST API. - const channelId = resolveAgentChannelId(pubkey); - if (!channelId) { - setActionErrorMessage("Cannot stop: agent is not in any channel"); - return; - } - await sendChannelMessage(channelId, "!shutdown", undefined, undefined, [ - pubkey, - ]); - setActionNoticeMessage( - "Shutdown command sent. Agent will stop shortly.", - ); - } else { - // Local agent: existing stop flow - await stopMutation.mutateAsync(pubkey); + const result = await stopManagedAgentWithRules({ + agent, + channels: channelsQuery.data ?? [], + relayAgents: relayAgentsQuery.data ?? [], + stopManagedAgent: stopMutation.mutateAsync, + }); + if (result.noticeMessage) { + setActionNoticeMessage(result.noticeMessage); } } catch (error) { setActionErrorMessage( @@ -257,59 +240,18 @@ export function AgentsView() { clearActionFeedback(); try { - // For remote agents, send !shutdown before deleting to avoid orphaning. const agent = managedAgents.find((a) => a.pubkey === pubkey); - if (agent?.backend.type === "provider" && agent.backendAgentId) { - const presence = - managedPresenceQuery.data?.[pubkey.trim().toLowerCase()]; - const channelId = resolveAgentChannelId(pubkey); - if (channelId) { - // If the agent is still online, send !shutdown and warn that - // deletion proceeds without waiting for confirmed exit. - if (presence === "online" || presence === "away") { - await sendChannelMessage( - channelId, - "!shutdown", - undefined, - undefined, - [pubkey], - ); - // eslint-disable-next-line no-alert - const confirmed = window.confirm( - "Shutdown command sent, but the agent may still be running. " + - "Deleting now removes the local record — the remote deployment " + - "will be orphaned if shutdown hasn't completed. Continue?", - ); - if (!confirmed) return; - } else { - // Offline presence means the process isn't connected, but the - // remote infrastructure (VM/container) may still exist. Confirm - // before removing the local record — it's the only management handle. - // eslint-disable-next-line no-alert - const confirmed = window.confirm( - "This agent is offline but the remote deployment may still exist. " + - "Deleting removes the local management record. Continue?", - ); - if (!confirmed) return; - } - } else { - // Can't send shutdown — warn user about orphaning. - // eslint-disable-next-line no-alert - const confirmed = window.confirm( - "This agent is deployed but not in any channel. " + - "Deleting will orphan the remote deployment (it will keep running). Continue?", - ); - if (!confirmed) return; - } - } - // Pass forceRemoteDelete for deployed provider agents — the backend - // rejects deletion of deployed remote agents without this flag. - const isDeployedRemote = - agent?.backend.type === "provider" && agent?.backendAgentId; - await deleteMutation.mutateAsync({ - pubkey, - forceRemoteDelete: isDeployedRemote ? true : undefined, + if (!agent) return; + + const result = await deleteManagedAgentWithRules({ + agent, + channels: channelsQuery.data ?? [], + deleteManagedAgent: deleteMutation.mutateAsync, + presenceLookup: managedPresenceQuery.data, + relayAgents: relayAgentsQuery.data ?? [], }); + if (result.cancelled) return; + if (logAgentPubkey === pubkey) { setLogAgentPubkey(null); } diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index fe118304f..20d874203 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -389,6 +389,19 @@ export function useAddChannelMembersMutation(channelId: string | null) { }); } +export async function removeChannelMemberWithManagedAgentCleanup( + channelId: string, + pubkey: string, +) { + await removeChannelMember(channelId, pubkey); + + try { + await cleanupManagedAgentIfOrphaned(pubkey, channelId); + } catch (error) { + console.warn("Failed to clean up managed agent:", error); + } +} + export function useRemoveChannelMemberMutation(channelId: string | null) { const queryClient = useQueryClient(); @@ -398,13 +411,7 @@ export function useRemoveChannelMemberMutation(channelId: string | null) { throw new Error("No channel selected."); } - await removeChannelMember(channelId, pubkey); - - try { - await cleanupManagedAgentIfOrphaned(pubkey, channelId); - } catch (error) { - console.warn("Failed to clean up managed agent:", error); - } + await removeChannelMemberWithManagedAgentCleanup(channelId, pubkey); }, onSettled: async () => { await Promise.all([ diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 2274426ba..06c419530 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -1,19 +1,14 @@ import * as React from "react"; - import { useAddChannelMembersMutation, useChannelMembersQuery, - useRemoveChannelMemberMutation, } from "@/features/channels/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; import { formatMemberName } from "@/features/channels/lib/memberUtils"; import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import { getPresenceLabel } from "@/features/presence/lib/presence"; import { usePresenceQuery } from "@/features/presence/hooks"; -import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import type { Channel, ChannelMember } from "@/shared/api/types"; -import { Button } from "@/shared/ui/button"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { Sheet, SheetContent, @@ -21,7 +16,10 @@ import { SheetHeader, SheetTitle, } from "@/shared/ui/sheet"; +import { MembersSidebarAgentControls } from "./MembersSidebarAgentControls"; import { ChannelMemberInviteCard } from "./ChannelMemberInviteCard"; +import { MembersSidebarMemberCard } from "./MembersSidebarMemberCard"; +import { useMembersSidebarActions } from "./useMembersSidebarActions"; type MembersSidebarProps = { channel: Channel | null; @@ -30,14 +28,6 @@ type MembersSidebarProps = { onOpenChange: (open: boolean) => void; }; -function formatRoleLabel(member: ChannelMember, memberIsBot: boolean) { - if (memberIsBot) { - return "Bot"; - } - - return `${member.role[0]?.toUpperCase() ?? ""}${member.role.slice(1)}`; -} - export function MembersSidebar({ channel, currentPubkey, @@ -47,13 +37,10 @@ export function MembersSidebar({ const channelId = channel?.id ?? null; const membersQuery = useChannelMembersQuery(channelId, open); const addMembersMutation = useAddChannelMembersMutation(channelId); - const removeMemberMutation = useRemoveChannelMemberMutation(channelId); const rawMembers = membersQuery.data ?? []; - const { people, bots, isBot, isMyBot } = useClassifiedMembers( - rawMembers, - currentPubkey, - ); + const { people, bots, isBot, isMyBot, managedAgentsQuery } = + useClassifiedMembers(rawMembers, currentPubkey); const allMemberPubkeys = React.useMemo( () => rawMembers.map((member) => member.pubkey), @@ -72,77 +59,98 @@ export function MembersSidebar({ selfMember?.role === "owner" || selfMember?.role === "admin"; const isArchived = channel?.archivedAt !== null && channel?.archivedAt !== undefined; + const managedAgentByPubkey = React.useMemo( + () => + new Map( + (managedAgentsQuery.data ?? []).map((agent) => [ + normalizePubkey(agent.pubkey), + agent, + ]), + ), + [managedAgentsQuery.data], + ); + const controllableManagedBots = React.useMemo( + () => + bots.flatMap((member) => { + const agent = managedAgentByPubkey.get(normalizePubkey(member.pubkey)); + return agent ? [agent] : []; + }), + [bots, managedAgentByPubkey], + ); + const canRemoveMember = React.useCallback( + (member: ChannelMember) => { + return ( + (selfMember?.role === "admin" && member.pubkey !== currentPubkey) || + (selfMember?.role === "owner" && isBot(member)) || + Boolean(selfMember && isMyBot(member)) || + member.pubkey === currentPubkey + ); + }, + [currentPubkey, isBot, isMyBot, selfMember], + ); + const removableManagedBots = React.useMemo( + () => + bots.flatMap((member) => { + if (!canRemoveMember(member)) { + return []; + } + + const agent = managedAgentByPubkey.get(normalizePubkey(member.pubkey)); + return agent ? [agent] : []; + }), + [bots, canRemoveMember, managedAgentByPubkey], + ); + const { + actionErrorMessage, + actionNoticeMessage, + handleLifecycleAction: handleAgentLifecycleAction, + handleRemoveAll, + handleRemoveMember, + handleRespawnAll, + handleStopAll, + hasControllableManagedBots, + hasRemovableManagedBots, + hasStoppableManagedBots, + isActionPending, + } = useMembersSidebarActions({ + channelId, + controllableManagedBots, + removableManagedBots, + currentPubkey, + onOpenChange, + }); if (!channel) { return null; } function renderMemberCard(member: ChannelMember, memberIsBot: boolean) { - // Any channel member can remove bots they own, regardless of role. - const canRemoveMember = - (selfMember?.role === "admin" && member.pubkey !== currentPubkey) || - (selfMember?.role === "owner" && isBot(member)) || - (selfMember && isMyBot(member)) || - (currentPubkey && member.pubkey === currentPubkey); - const memberLabel = formatMemberName(member, currentPubkey); - const profile = - memberProfilesQuery.data?.profiles[member.pubkey.toLowerCase()] ?? null; - const presenceStatus = - memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null; - const roleLabel = formatRoleLabel(member, memberIsBot); - return ( -
-
- -
-

- {memberLabel} -

-
- {presenceStatus ? ( - <> - - {getPresenceLabel(presenceStatus)} - - - ) : null} - {roleLabel} -
-
-
- {canRemoveMember ? ( - - ) : null} -
+ managedAgent={ + memberIsBot + ? managedAgentByPubkey.get(normalizePubkey(member.pubkey)) + : undefined + } + member={member} + memberIsBot={memberIsBot} + memberLabel={formatMemberName(member, currentPubkey)} + onManagedAgentAction={(agent) => { + void handleAgentLifecycleAction(agent); + }} + onRemoveMember={handleRemoveMember} + presenceStatus={ + memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null + } + profileAvatarUrl={ + memberProfilesQuery.data?.profiles[member.pubkey.toLowerCase()] + ?.avatarUrl ?? null + } + /> ); } @@ -197,11 +205,28 @@ export function MembersSidebar({
-
+

Bots

{bots.length} + {hasControllableManagedBots ? ( + { + void handleRemoveAll(); + }} + onRespawnAll={() => { + void handleRespawnAll(); + }} + onStopAll={() => { + void handleStopAll(); + }} + /> + ) : null}
{bots.length > 0 ? ( @@ -216,9 +241,21 @@ export function MembersSidebar({
- {removeMemberMutation.error instanceof Error ? ( -

- {removeMemberMutation.error.message} + {actionNoticeMessage ? ( +

+ {actionNoticeMessage} +

+ ) : null} + + {actionErrorMessage ? ( +

+ {actionErrorMessage}

) : null} diff --git a/desktop/src/features/channels/ui/MembersSidebarAgentControls.tsx b/desktop/src/features/channels/ui/MembersSidebarAgentControls.tsx new file mode 100644 index 000000000..303f6748a --- /dev/null +++ b/desktop/src/features/channels/ui/MembersSidebarAgentControls.tsx @@ -0,0 +1,78 @@ +import { Ellipsis, Play, Square, Trash2 } from "lucide-react"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +type MembersSidebarAgentControlsProps = { + canBulkRemove: boolean; + canBulkRespawn: boolean; + canBulkStop: boolean; + disabled: boolean; + onRemoveAll: () => void; + onRespawnAll: () => void; + onStopAll: () => void; +}; + +export function MembersSidebarAgentControls({ + canBulkRemove, + canBulkRespawn, + canBulkStop, + disabled, + onRemoveAll, + onRespawnAll, + onStopAll, +}: MembersSidebarAgentControlsProps) { + return ( + + + + + event.preventDefault()} + > + + + Spawn or respawn all + + + + Stop all + + {canBulkRemove ? ( + <> + + + + Remove all from channel + + + ) : null} + + + ); +} diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx new file mode 100644 index 000000000..238530e47 --- /dev/null +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -0,0 +1,205 @@ +import { Ellipsis, Play, RotateCcw, Square, Trash2 } from "lucide-react"; + +import { + getManagedAgentPrimaryActionLabel, + isManagedAgentActive, +} from "@/features/agents/lib/managedAgentControlActions"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { getPresenceLabel } from "@/features/presence/lib/presence"; +import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; +import type { + ChannelMember, + ManagedAgent, + PresenceStatus, +} from "@/shared/api/types"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +type MembersSidebarMemberCardProps = { + canRemoveMember: boolean; + isActionPending: boolean; + isArchived: boolean; + managedAgent?: ManagedAgent; + member: ChannelMember; + memberIsBot: boolean; + memberLabel: string; + onManagedAgentAction: (agent: ManagedAgent) => void; + onRemoveMember: (member: ChannelMember) => void; + presenceStatus?: PresenceStatus | null; + profileAvatarUrl?: string | null; +}; + +function formatRoleLabel(member: ChannelMember, memberIsBot: boolean) { + if (memberIsBot) { + return "Bot"; + } + + return `${member.role[0]?.toUpperCase() ?? ""}${member.role.slice(1)}`; +} + +function formatManagedAgentStatus(agent: ManagedAgent) { + switch (agent.status) { + case "running": + return "Running"; + case "stopped": + return "Stopped"; + case "deployed": + return "Deployed"; + case "not_deployed": + return "Not deployed"; + } +} + +export function MembersSidebarMemberCard({ + canRemoveMember, + isActionPending, + isArchived, + managedAgent, + member, + memberIsBot, + memberLabel, + onManagedAgentAction, + onRemoveMember, + presenceStatus, + profileAvatarUrl, +}: MembersSidebarMemberCardProps) { + const roleLabel = formatRoleLabel(member, memberIsBot); + const disabled = isActionPending || isArchived; + const hasActions = memberIsBot + ? Boolean(managedAgent) || canRemoveMember + : canRemoveMember; + + return ( +
+
+ +
+

+ {memberLabel} +

+
+ {presenceStatus ? ( + <> + + {getPresenceLabel(presenceStatus)} + + + ) : null} + {roleLabel} + {managedAgent ? ( + <> + + + {formatManagedAgentStatus(managedAgent)} + + + ) : null} +
+
+
+ {hasActions ? ( + + ) : null} +
+ ); +} + +function MemberActionsMenu({ + canRemoveMember, + disabled, + managedAgent, + member, + memberIsBot, + onManagedAgentAction, + onRemoveMember, +}: { + canRemoveMember: boolean; + disabled: boolean; + managedAgent?: ManagedAgent; + member: ChannelMember; + memberIsBot: boolean; + onManagedAgentAction: (agent: ManagedAgent) => void; + onRemoveMember: (member: ChannelMember) => void; +}) { + return ( + + + + + event.preventDefault()} + > + {memberIsBot && managedAgent ? ( + <> + onManagedAgentAction(managedAgent)} + > + {getManagedAgentActionIcon(managedAgent)} + {getManagedAgentPrimaryActionLabel(managedAgent)} + + {canRemoveMember ? : null} + + ) : null} + {canRemoveMember ? ( + onRemoveMember(member)} + > + + Remove from channel + + ) : null} + + + ); +} + +function getManagedAgentActionIcon(agent: ManagedAgent) { + if (isManagedAgentActive(agent)) { + return ; + } + + if (agent.backend.type === "local" && agent.status === "stopped") { + return ; + } + + return ; +} diff --git a/desktop/src/features/channels/ui/useMembersSidebarActions.ts b/desktop/src/features/channels/ui/useMembersSidebarActions.ts new file mode 100644 index 000000000..9143b6514 --- /dev/null +++ b/desktop/src/features/channels/ui/useMembersSidebarActions.ts @@ -0,0 +1,306 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { + useStartManagedAgentMutation, + useStopManagedAgentMutation, +} from "@/features/agents/hooks"; +import { + respawnManagedAgentWithRules, + isManagedAgentActive, + startManagedAgentWithRules, + stopManagedAgentWithRules, +} from "@/features/agents/lib/managedAgentControlActions"; +import { + channelsQueryKey, + removeChannelMemberWithManagedAgentCleanup, + useRemoveChannelMemberMutation, +} from "@/features/channels/hooks"; +import type { ChannelMember, ManagedAgent } from "@/shared/api/types"; + +type UseMembersSidebarActionsOptions = { + channelId: string | null; + controllableManagedBots: readonly ManagedAgent[]; + removableManagedBots: readonly ManagedAgent[]; + currentPubkey?: string; + onOpenChange: (open: boolean) => void; +}; + +type BulkAgentActionResult = { + cancelled?: boolean; +}; + +const EMPTY_AGENT_CONTEXT = { + channels: [], + relayAgents: [], +} as const; + +export function useMembersSidebarActions({ + channelId, + controllableManagedBots, + removableManagedBots, + currentPubkey, + onOpenChange, +}: UseMembersSidebarActionsOptions) { + const queryClient = useQueryClient(); + const removeMemberMutation = useRemoveChannelMemberMutation(channelId); + const startManagedAgentMutation = useStartManagedAgentMutation(); + const stopManagedAgentMutation = useStopManagedAgentMutation(); + const [actionNoticeMessage, setActionNoticeMessage] = React.useState< + string | null + >(null); + const [actionErrorMessage, setActionErrorMessage] = React.useState< + string | null + >(null); + const [activeActionKey, setActiveActionKey] = React.useState( + null, + ); + + const stoppableManagedBots = React.useMemo( + () => + controllableManagedBots.filter((agent) => isManagedAgentActive(agent)), + [controllableManagedBots], + ); + + const isActionPending = + activeActionKey !== null || + removeMemberMutation.isPending || + startManagedAgentMutation.isPending || + stopManagedAgentMutation.isPending; + + const clearActionFeedback = React.useCallback(() => { + setActionNoticeMessage(null); + setActionErrorMessage(null); + }, []); + + async function runBulkAgentAction({ + action, + actionKey, + agents, + failureMessage, + onSettled, + successMessage, + }: { + action: (agent: ManagedAgent) => Promise; + actionKey: string; + agents: readonly ManagedAgent[]; + failureMessage: string; + onSettled?: () => Promise; + successMessage: (count: number) => string; + }) { + clearActionFeedback(); + setActiveActionKey(actionKey); + const failures: Array<{ error: string; name: string }> = []; + let successCount = 0; + + try { + for (const agent of agents) { + try { + const result = await action(agent); + if (result?.cancelled) { + break; + } + + successCount += 1; + } catch (error) { + failures.push({ + error: error instanceof Error ? error.message : failureMessage, + name: agent.name, + }); + } + } + + if (successCount > 0) { + setActionNoticeMessage(successMessage(successCount)); + } + + const failureSummary = formatFailureSummary(failures); + if (failureSummary) { + setActionErrorMessage(failureSummary); + } + } finally { + if (onSettled) { + await onSettled(); + } + setActiveActionKey(null); + } + } + + async function handleLifecycleAction(agent: ManagedAgent) { + clearActionFeedback(); + setActiveActionKey(`agent:${agent.pubkey}`); + + try { + if (isManagedAgentActive(agent)) { + await stopManagedAgentWithRules({ + agent, + ...EMPTY_AGENT_CONTEXT, + preferredChannelId: channelId, + stopManagedAgent: stopManagedAgentMutation.mutateAsync, + }); + setActionNoticeMessage( + agent.backend.type === "provider" + ? `Shutdown command sent to ${agent.name}.` + : `Stopped ${agent.name}.`, + ); + return; + } + + await startManagedAgentWithRules({ + agent, + startManagedAgent: startManagedAgentMutation.mutateAsync, + }); + setActionNoticeMessage(getLifecycleSuccessMessage(agent)); + } catch (error) { + setActionErrorMessage( + error instanceof Error ? error.message : "Failed to control agent.", + ); + } finally { + setActiveActionKey(null); + } + } + + async function handleRespawnAll() { + await runBulkAgentAction({ + action: async (agent) => { + await respawnManagedAgentWithRules({ + agent, + startManagedAgent: startManagedAgentMutation.mutateAsync, + stopManagedAgent: stopManagedAgentMutation.mutateAsync, + }); + return undefined; + }, + actionKey: "bulk-respawn", + agents: controllableManagedBots, + failureMessage: "Failed to respawn agent.", + successMessage: (count) => + `Spawned or respawned ${formatCountLabel(count, "agent", "agents")}.`, + }); + } + + async function handleStopAll() { + await runBulkAgentAction({ + action: (agent) => + stopManagedAgentWithRules({ + agent, + ...EMPTY_AGENT_CONTEXT, + preferredChannelId: channelId, + stopManagedAgent: stopManagedAgentMutation.mutateAsync, + }), + actionKey: "bulk-stop", + agents: stoppableManagedBots, + failureMessage: "Failed to stop agent.", + successMessage: (count) => + `Stopped or requested shutdown for ${formatCountLabel( + count, + "agent", + "agents", + )}.`, + }); + } + + async function handleRemoveAll() { + await runBulkAgentAction({ + action: async (agent) => { + await removeManagedBotMembership(agent.pubkey); + return undefined; + }, + actionKey: "bulk-remove", + agents: removableManagedBots, + failureMessage: "Failed to remove bot from channel.", + onSettled: invalidateSidebarQueries, + successMessage: (count) => + `Removed ${formatCountLabel(count, "managed bot", "managed bots")} from this channel.`, + }); + } + + const handleRemoveMember = React.useCallback( + (member: ChannelMember) => { + clearActionFeedback(); + setActiveActionKey(`remove:${member.pubkey}`); + void removeMemberMutation + .mutateAsync(member.pubkey) + .then(() => { + if (member.pubkey === currentPubkey) { + onOpenChange(false); + } + }) + .catch((error: unknown) => { + setActionErrorMessage( + error instanceof Error ? error.message : "Failed to remove member.", + ); + }) + .finally(() => { + setActiveActionKey(null); + }); + }, + [clearActionFeedback, currentPubkey, onOpenChange, removeMemberMutation], + ); + + async function removeManagedBotMembership(pubkey: string) { + if (!channelId) { + throw new Error("No channel selected."); + } + + await removeChannelMemberWithManagedAgentCleanup(channelId, pubkey); + } + + async function invalidateSidebarQueries() { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: channelsQueryKey }), + channelId + ? queryClient.invalidateQueries({ queryKey: ["channels", channelId] }) + : Promise.resolve(), + queryClient.invalidateQueries({ queryKey: ["managed-agents"] }), + queryClient.invalidateQueries({ queryKey: ["relay-agents"] }), + ]); + } + + return { + actionErrorMessage, + actionNoticeMessage, + handleLifecycleAction, + handleRemoveAll, + handleRemoveMember, + handleRespawnAll, + handleStopAll, + isActionPending, + hasControllableManagedBots: controllableManagedBots.length > 0, + hasRemovableManagedBots: removableManagedBots.length > 0, + hasStoppableManagedBots: stoppableManagedBots.length > 0, + }; +} + +function getLifecycleSuccessMessage(agent: ManagedAgent) { + if (agent.backend.type === "provider") { + return `Deployed ${agent.name}.`; + } + + return agent.status === "stopped" + ? `Respawned ${agent.name}.` + : `Spawned ${agent.name}.`; +} + +function formatFailureSummary( + failures: Array<{ + error: string; + name: string; + }>, +) { + if (failures.length === 0) { + return null; + } + + if (failures.length === 1) { + const [failure] = failures; + return `${failure.name}: ${failure.error}`; + } + + return failures + .map((failure) => `${failure.name}: ${failure.error}`) + .join("; "); +} + +function formatCountLabel(count: number, singular: string, plural: string) { + return `${count} ${count === 1 ? singular : plural}`; +} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 06ae1990f..ce920195a 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -150,6 +150,7 @@ export function ChannelMenuButton({ hasUnread && "font-semibold text-sidebar-foreground hover:text-sidebar-foreground", )} + data-channel-id={channel.id} data-testid={`channel-${channel.name}`} isActive={isActive} onClick={() => onSelectChannel(channel.id)} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 262e34fe1..d43ec3496 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -278,6 +278,7 @@ type RawRelayAgent = { name: string; agent_type: string; channels: string[]; + channel_ids: string[]; capabilities: string[]; status: PresenceStatus; }; @@ -616,6 +617,7 @@ function cloneRelayAgent(agent: RawRelayAgent): RawRelayAgent { return { ...agent, channels: [...agent.channels], + channel_ids: [...agent.channel_ids], capabilities: [...agent.capabilities], }; } @@ -1060,6 +1062,10 @@ let mockRelayAgents: RawRelayAgent[] = [ name: "alice", agent_type: "goose", channels: ["general", "agents"], + channel_ids: [ + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + "94a444a4-c0a3-5966-ab05-530c6ddc2301", + ], capabilities: ["search", "summaries", "workflows"], status: "online", }, @@ -1068,6 +1074,7 @@ let mockRelayAgents: RawRelayAgent[] = [ name: "charlie", agent_type: "codex", channels: ["general"], + channel_ids: ["9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"], capabilities: ["code", "reviews"], status: "away", }, @@ -1328,19 +1335,38 @@ function syncMockRelayAgentsFromManagedAgents() { !mockManagedAgents.some((managed) => managed.pubkey === agent.pubkey), ); const managedAgentsAsRelay: RawRelayAgent[] = mockManagedAgents.map( - (agent) => ({ - pubkey: agent.pubkey, - name: agent.name, - agent_type: agent.agent_command, - channels: ["agents"], - capabilities: ["messages", "channels", "mcp"], - status: agent.status === "running" ? "online" : "offline", - }), + (agent) => { + const memberships = getManagedAgentRelayMembership(agent.pubkey); + + return { + pubkey: agent.pubkey, + name: agent.name, + agent_type: agent.agent_command, + channels: memberships.channels, + channel_ids: memberships.channelIds, + capabilities: ["messages", "channels", "mcp"], + status: + agent.status === "running" || agent.status === "deployed" + ? "online" + : "offline", + }; + }, ); mockRelayAgents = [...baseAgents, ...managedAgentsAsRelay]; } +function getManagedAgentRelayMembership(pubkey: string) { + const memberships = mockChannels.filter((channel) => + channel.members.some((member) => member.pubkey === pubkey), + ); + + return { + channelIds: memberships.map((channel) => channel.id), + channels: memberships.map((channel) => channel.name), + }; +} + function getConfig(): E2eConfig | undefined { return window.__SPROUT_E2E__; } @@ -2583,6 +2609,7 @@ async function handleAddChannelMembers( syncMockChannel(channel); touchMockChannel(channel); + syncMockRelayAgentsFromManagedAgents(); return { added, errors, @@ -2624,6 +2651,7 @@ async function handleRemoveChannelMember( ); syncMockChannel(channel); touchMockChannel(channel); + syncMockRelayAgentsFromManagedAgents(); return; } diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 0eedaaed1..82dfcf38e 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -29,6 +29,115 @@ async function openMembersSidebar( await expect(page.getByTestId("members-sidebar")).toBeVisible(); } +async function openMemberMenu( + page: import("@playwright/test").Page, + pubkey: string, +) { + const row = page.getByTestId(`sidebar-member-${pubkey}`); + const trigger = page.getByTestId(`sidebar-member-menu-${pubkey}`); + await row.scrollIntoViewIfNeeded(); + await row.hover(); + // Workaround: @radix-ui/react-dropdown-menu@2.1.16 ignores pointer-based + // re-opens after a menu item click (onCloseAutoFocus race). Opening via + // keyboard (focus + Enter) is reliable. Revisit if Radix fixes this. + await trigger.focus(); + await trigger.press("Enter"); + await expect(trigger).toHaveAttribute("data-state", "open"); +} + +async function addGenericAgent( + page: import("@playwright/test").Page, + channelName: string, + agentName: string, +) { + await page.getByTestId(`channel-${channelName}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channelName); + await page.getByTestId("channel-add-bot-trigger").click(); + await expect(page.getByRole("heading", { name: "Add agents" })).toBeVisible(); + await page.getByRole("button", { name: "Generic" }).click(); + await page.locator("#channel-generic-name").fill(agentName); + await page + .locator("#channel-generic-prompt") + .fill("Watch the channel and help when asked."); + await page.getByRole("button", { name: "Add agent" }).click(); + await expect(page.getByRole("heading", { name: "Add agents" })).toHaveCount( + 0, + ); +} + +async function getManagedAgentPubkey( + page: import("@playwright/test").Page, + agentName: string, +) { + await page.getByTestId("open-agents-view").click(); + const managedAgentRow = page + .locator('[data-testid^="managed-agent-"]') + .filter({ hasText: agentName }); + await expect(managedAgentRow).toHaveCount(1); + const managedAgentTestId = await managedAgentRow + .first() + .getAttribute("data-testid"); + if (!managedAgentTestId) { + throw new Error("Managed agent row test id missing."); + } + + return managedAgentTestId.replace("managed-agent-", ""); +} + +async function readCommandLog(page: import("@playwright/test").Page) { + return page.evaluate(() => { + return ( + ( + window as Window & { + __SPROUT_E2E_COMMANDS__?: string[]; + } + ).__SPROUT_E2E_COMMANDS__ ?? [] + ); + }); +} + +async function invokeMockCommand( + page: import("@playwright/test").Page, + command: string, + payload?: Record, +) { + await page.waitForFunction(() => { + return Boolean( + ( + window as Window & { + __SPROUT_E2E_INVOKE_MOCK_COMMAND__?: unknown; + } + ).__SPROUT_E2E_INVOKE_MOCK_COMMAND__, + ); + }); + + return page.evaluate( + async ({ + command, + payload, + }: { + command: string; + payload?: Record; + }) => { + const invoke = ( + window as Window & { + __SPROUT_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: Record, + ) => Promise; + } + ).__SPROUT_E2E_INVOKE_MOCK_COMMAND__; + + if (!invoke) { + throw new Error("Mock bridge is not installed."); + } + + return invoke(command, payload); + }, + { command, payload }, + ) as Promise; +} + test.beforeEach(async ({ page }) => { await installMockBridge(page); }); @@ -624,6 +733,7 @@ test("members sidebar can invite and remove members", async ({ page }) => { ).toContainText("charlie"); await expect(page.getByTestId("channel-members-trigger")).toContainText("4"); + await openMemberMenu(page, TEST_IDENTITIES.charlie.pubkey); await page .getByTestId(`sidebar-remove-member-${TEST_IDENTITIES.charlie.pubkey}`) .click(); @@ -699,59 +809,247 @@ test("removing a channel-scoped agent also cleans up the managed agent record", const agentName = `cleanup-agent-${Date.now()}`; await page.goto("/"); - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); + await addGenericAgent(page, "general", agentName); + const agentPubkey = await getManagedAgentPubkey(page, agentName); - await page.getByTestId("channel-add-bot-trigger").click(); - await expect(page.getByRole("heading", { name: "Add agents" })).toBeVisible(); + await page.getByTestId("channel-general").click(); + await openMembersSidebar(page, "general"); - await page.getByRole("button", { name: "Generic" }).click(); - await page.locator("#channel-generic-name").fill(agentName); - await page - .locator("#channel-generic-prompt") - .fill("Watch the channel and help when asked."); - await page.getByRole("button", { name: "Add agent" }).click(); - await expect(page.getByRole("heading", { name: "Add agents" })).toHaveCount( + await openMemberMenu(page, agentPubkey); + await page.getByTestId(`sidebar-remove-member-${agentPubkey}`).click(); + await expect(page.getByTestId(`sidebar-member-${agentPubkey}`)).toHaveCount( 0, ); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("members-sidebar")).not.toBeVisible(); await page.getByTestId("open-agents-view").click(); - const managedAgentRow = page - .locator('[data-testid^="managed-agent-"]') - .filter({ hasText: agentName }); - await expect(managedAgentRow).toHaveCount(1); + await expect(page.getByTestId(`managed-agent-${agentPubkey}`)).toHaveCount(0); - const managedAgentTestId = await managedAgentRow - .first() - .getAttribute("data-testid"); - if (!managedAgentTestId) { - throw new Error("Managed agent row test id missing."); + const commands = await readCommandLog(page); + expect(commands).toContain("delete_managed_agent"); +}); + +test("members sidebar can respawn a stopped managed bot", async ({ page }) => { + const agentName = `sidebar-agent-${Date.now()}`; + + await page.goto("/"); + await addGenericAgent(page, "general", agentName); + + const agentPubkey = await getManagedAgentPubkey(page, agentName); + const baselineCommands = await readCommandLog(page); + const baselineStartCount = baselineCommands.filter( + (command) => command === "start_managed_agent", + ).length; + const baselineStopCount = baselineCommands.filter( + (command) => command === "stop_managed_agent", + ).length; + + await openMembersSidebar(page, "general"); + + const agentStatus = page.getByTestId( + `sidebar-managed-agent-status-${agentPubkey}`, + ); + const agentAction = page.getByTestId(`sidebar-agent-action-${agentPubkey}`); + + await expect(agentStatus).toContainText("Running"); + await openMemberMenu(page, agentPubkey); + await expect(agentAction).toContainText("Stop"); + await agentAction.click(); + + await expect(agentStatus).toContainText("Stopped"); + await openMemberMenu(page, agentPubkey); + await expect(agentAction).toContainText("Respawn"); + await agentAction.click(); + + await expect(agentStatus).toContainText("Running"); + await expect(page.getByTestId("members-sidebar-action-notice")).toContainText( + `Respawned ${agentName}.`, + ); + + const commands = await readCommandLog(page); + expect( + commands.filter((command) => command === "start_managed_agent").length, + ).toBe(baselineStartCount + 1); + expect( + commands.filter((command) => command === "stop_managed_agent").length, + ).toBe(baselineStopCount + 1); +}); + +test("members sidebar supports bulk remove for managed bots", async ({ + page, +}) => { + const firstAgentName = `sidebar-remove-a-${Date.now()}`; + const secondAgentName = `sidebar-remove-b-${Date.now()}`; + + await page.goto("/"); + await addGenericAgent(page, "general", firstAgentName); + await addGenericAgent(page, "general", secondAgentName); + + const firstAgentPubkey = await getManagedAgentPubkey(page, firstAgentName); + const secondAgentPubkey = await getManagedAgentPubkey(page, secondAgentName); + + await openMembersSidebar(page, "general"); + await expect( + page.getByTestId("members-sidebar-agent-controls"), + ).toBeVisible(); + + await page.getByTestId("members-sidebar-agent-controls").click(); + await page.getByTestId("members-sidebar-remove-all").click(); + await expect(page.getByTestId("members-sidebar-action-notice")).toContainText( + "Removed 2 managed bots from this channel.", + ); + await expect( + page.getByTestId(`sidebar-member-${firstAgentPubkey}`), + ).toHaveCount(0); + await expect( + page.getByTestId(`sidebar-member-${secondAgentPubkey}`), + ).toHaveCount(0); + + await page.keyboard.press("Escape"); + await expect(page.getByTestId("members-sidebar")).not.toBeVisible(); + + await page.getByTestId("open-agents-view").click(); + await expect( + page.getByTestId(`managed-agent-${firstAgentPubkey}`), + ).toHaveCount(0); + await expect( + page.getByTestId(`managed-agent-${secondAgentPubkey}`), + ).toHaveCount(0); + + const commands = await readCommandLog(page); + expect( + commands.filter((command) => command === "remove_channel_member"), + ).toHaveLength(2); + expect( + commands.filter((command) => command === "delete_managed_agent"), + ).toHaveLength(2); +}); + +test("removing a multi-channel managed bot keeps its record until it is orphaned", async ({ + page, +}) => { + const agentName = `multi-channel-agent-${Date.now()}`; + const secondChannelName = `multi-home-${Date.now()}`; + + await page.goto("/"); + await addGenericAgent(page, "general", agentName); + const agentPubkey = await getManagedAgentPubkey(page, agentName); + + await page.getByRole("button", { name: "Create a stream" }).click(); + await page.getByTestId("create-stream-name").fill(secondChannelName); + await page + .getByTestId("create-stream-description") + .fill("Second home for managed bot cleanup coverage"); + await page + .getByTestId("create-stream-form") + .getByRole("button", { name: "Create" }) + .click(); + await expect(page.getByTestId("chat-title")).toHaveText(secondChannelName); + + const secondChannelId = await page + .getByTestId(`channel-${secondChannelName}`) + .getAttribute("data-channel-id"); + if (!secondChannelId) { + throw new Error("Second channel id missing."); } - const agentPubkey = managedAgentTestId.replace("managed-agent-", ""); - await page.getByTestId("channel-general").click(); + await invokeMockCommand(page, "add_channel_members", { + channelId: secondChannelId, + pubkeys: [agentPubkey], + role: "bot", + }); + + // Snapshot command counts before removals so assertions are relative. + const baseline = await readCommandLog(page); + const baselineRemoves = baseline.filter( + (c) => c === "remove_channel_member", + ).length; + const baselineDeletes = baseline.filter( + (c) => c === "delete_managed_agent", + ).length; + await openMembersSidebar(page, "general"); + await openMemberMenu(page, agentPubkey); + await page.getByTestId(`sidebar-remove-member-${agentPubkey}`).click(); + await expect(page.getByTestId(`sidebar-member-${agentPubkey}`)).toHaveCount( + 0, + ); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("members-sidebar")).not.toBeVisible(); + + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId(`managed-agent-${agentPubkey}`)).toHaveCount(1); - const removeButton = page.getByTestId(`sidebar-remove-member-${agentPubkey}`); - await expect(removeButton).toBeVisible(); - await removeButton.click(); - await expect(removeButton).toHaveCount(0); + let commands = await readCommandLog(page); + // First removal: 1 remove_channel_member, bot still in second channel + // so no delete_managed_agent yet. + expect( + commands.filter((c) => c === "remove_channel_member").length - + baselineRemoves, + ).toBe(1); + expect( + commands.filter((c) => c === "delete_managed_agent").length - + baselineDeletes, + ).toBe(0); + + await openMembersSidebar(page, secondChannelName); + await openMemberMenu(page, agentPubkey); + await page.getByTestId(`sidebar-remove-member-${agentPubkey}`).click(); + await expect(page.getByTestId(`sidebar-member-${agentPubkey}`)).toHaveCount( + 0, + ); await page.keyboard.press("Escape"); await expect(page.getByTestId("members-sidebar")).not.toBeVisible(); await page.getByTestId("open-agents-view").click(); await expect(page.getByTestId(`managed-agent-${agentPubkey}`)).toHaveCount(0); - const commands = await page.evaluate(() => { - return ( - ( - window as Window & { - __SPROUT_E2E_COMMANDS__?: string[]; - } - ).__SPROUT_E2E_COMMANDS__ ?? [] - ); - }); - expect(commands).toContain("delete_managed_agent"); + commands = await readCommandLog(page); + // Second removal: bot is now orphaned, so cleanup deletes the managed agent. + expect( + commands.filter((c) => c === "remove_channel_member").length - + baselineRemoves, + ).toBe(2); + expect( + commands.filter((c) => c === "delete_managed_agent").length - + baselineDeletes, + ).toBe(1); +}); + +test("bulk remove stays hidden when row-level remove is not allowed", async ({ + page, +}) => { + const alicePubkey = + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; + + await page.goto("/"); + + // Join the "design" channel (unjoined by default) via the channel browser. + // The user becomes a regular member — not admin/owner. + await page.getByTestId("browse-channels").click(); + await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); + await page + .getByTestId("browse-channel-design") + .getByRole("button", { name: "Join" }) + .click(); + await expect(page.getByTestId("chat-title")).toHaveText("design"); + + await openMembersSidebar(page, "design"); + + // Alice is a relay-observed bot in design (present in mockRelayAgents) that + // the user does not manage locally. Since there is no local managed agent + // for alice, hasActions is false and no 3-dot menu renders. + await expect(page.getByTestId(`sidebar-member-${alicePubkey}`)).toBeVisible(); + await expect( + page.getByTestId(`sidebar-member-menu-${alicePubkey}`), + ).toHaveCount(0); + + // Bulk agent controls only render when hasControllableManagedBots is true. + // Since no bots in design have a local managed agent, the controls are absent. + await expect(page.getByTestId("members-sidebar-agent-controls")).toHaveCount( + 0, + ); }); test("open channel management supports join and leave", async ({ page }) => {