Add agent lifecycle controls to channel members sidebar (#291)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-10 16:08:20 -07:00
committed by GitHub
parent fc4f2f1351
commit e333371988
11 changed files with 1334 additions and 233 deletions

View File

@@ -45,6 +45,8 @@ pub struct RelayAgentInfo {
pub name: String,
pub agent_type: String,
pub channels: Vec<String>,
#[serde(default)]
pub channel_ids: Vec<String>,
pub capabilities: Vec<String>,
pub status: String,
}

View File

@@ -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<unknown>;
type StopManagedAgent = (pubkey: string) => Promise<unknown>;
type DeleteManagedAgent = (input: DeleteManagedAgentInput) => Promise<unknown>;
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<ManagedAgent, "status">) {
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<ManagedAgent, "pubkey">,
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<ManagedAgentActionResult> {
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<ManagedAgentActionResult> {
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 {};
}

View File

@@ -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);
}

View File

@@ -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([

View File

@@ -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 (
<div
className="flex items-center justify-between gap-3 rounded-xl border border-border/80 bg-background px-3 py-2.5"
data-testid={`sidebar-member-${member.pubkey}`}
<MembersSidebarMemberCard
canRemoveMember={canRemoveMember(member)}
isActionPending={isActionPending}
isArchived={isArchived}
key={member.pubkey}
>
<div className="flex min-w-0 items-center gap-3">
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="h-9 w-9 rounded-full text-[11px] shadow-none"
iconClassName="h-4 w-4"
label={memberLabel}
/>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm font-medium leading-5">
{memberLabel}
</p>
<div
className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"
data-testid={`sidebar-member-presence-${member.pubkey}`}
>
{presenceStatus ? (
<>
<PresenceDot className="h-2 w-2" status={presenceStatus} />
<span>{getPresenceLabel(presenceStatus)}</span>
<span aria-hidden="true">&middot;</span>
</>
) : null}
<span>{roleLabel}</span>
</div>
</div>
</div>
{canRemoveMember ? (
<Button
className="h-8 shrink-0 rounded-full px-2.5 text-xs text-muted-foreground hover:text-foreground"
data-testid={`sidebar-remove-member-${member.pubkey}`}
disabled={removeMemberMutation.isPending || isArchived}
onClick={() => {
void removeMemberMutation.mutateAsync(member.pubkey).then(() => {
if (member.pubkey === currentPubkey) {
onOpenChange(false);
}
});
}}
size="sm"
type="button"
variant="ghost"
>
Remove
</Button>
) : null}
</div>
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({
</section>
<section className="space-y-2.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold tracking-tight">Bots</h2>
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{bots.length}
</span>
{hasControllableManagedBots ? (
<MembersSidebarAgentControls
canBulkRemove={hasRemovableManagedBots}
canBulkRespawn={hasControllableManagedBots}
canBulkStop={hasStoppableManagedBots}
disabled={isActionPending || isArchived}
onRemoveAll={() => {
void handleRemoveAll();
}}
onRespawnAll={() => {
void handleRespawnAll();
}}
onStopAll={() => {
void handleStopAll();
}}
/>
) : null}
</div>
<div className="space-y-2" data-testid="members-sidebar-bots">
{bots.length > 0 ? (
@@ -216,9 +241,21 @@ export function MembersSidebar({
</div>
</section>
{removeMemberMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{removeMemberMutation.error.message}
{actionNoticeMessage ? (
<p
className="text-sm text-muted-foreground"
data-testid="members-sidebar-action-notice"
>
{actionNoticeMessage}
</p>
) : null}
{actionErrorMessage ? (
<p
className="text-sm text-destructive"
data-testid="members-sidebar-action-error"
>
{actionErrorMessage}
</p>
) : null}
</div>

View File

@@ -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 (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
className="ml-auto flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
data-testid="members-sidebar-agent-controls"
type="button"
>
<Ellipsis className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
data-testid="members-sidebar-respawn-all"
disabled={disabled || !canBulkRespawn}
onClick={onRespawnAll}
>
<Play className="h-4 w-4" />
Spawn or respawn all
</DropdownMenuItem>
<DropdownMenuItem
data-testid="members-sidebar-stop-all"
disabled={disabled || !canBulkStop}
onClick={onStopAll}
>
<Square className="h-4 w-4" />
Stop all
</DropdownMenuItem>
{canBulkRemove ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
data-testid="members-sidebar-remove-all"
disabled={disabled}
onClick={onRemoveAll}
>
<Trash2 className="h-4 w-4" />
Remove all from channel
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -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 (
<div
className="group flex items-center justify-between gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-muted/40"
data-testid={`sidebar-member-${member.pubkey}`}
>
<div className="flex min-w-0 items-center gap-3">
<ProfileAvatar
avatarUrl={profileAvatarUrl ?? null}
className="h-9 w-9 rounded-full text-[11px] shadow-none"
iconClassName="h-4 w-4"
label={memberLabel}
/>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm font-medium leading-5">
{memberLabel}
</p>
<div
className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"
data-testid={`sidebar-member-presence-${member.pubkey}`}
>
{presenceStatus ? (
<>
<PresenceDot className="h-2 w-2" status={presenceStatus} />
<span>{getPresenceLabel(presenceStatus)}</span>
<span aria-hidden="true">&middot;</span>
</>
) : null}
<span>{roleLabel}</span>
{managedAgent ? (
<>
<span aria-hidden="true">&middot;</span>
<span
className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground"
data-testid={`sidebar-managed-agent-status-${member.pubkey}`}
>
{formatManagedAgentStatus(managedAgent)}
</span>
</>
) : null}
</div>
</div>
</div>
{hasActions ? (
<MemberActionsMenu
canRemoveMember={canRemoveMember}
disabled={disabled}
managedAgent={managedAgent}
member={member}
memberIsBot={memberIsBot}
onManagedAgentAction={onManagedAgentAction}
onRemoveMember={onRemoveMember}
/>
) : null}
</div>
);
}
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 (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-all group-hover:opacity-100 hover:bg-muted hover:text-foreground data-[state=open]:opacity-100"
data-testid={`sidebar-member-menu-${member.pubkey}`}
type="button"
>
<Ellipsis className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
{memberIsBot && managedAgent ? (
<>
<DropdownMenuItem
data-testid={`sidebar-agent-action-${member.pubkey}`}
disabled={disabled}
onClick={() => onManagedAgentAction(managedAgent)}
>
{getManagedAgentActionIcon(managedAgent)}
{getManagedAgentPrimaryActionLabel(managedAgent)}
</DropdownMenuItem>
{canRemoveMember ? <DropdownMenuSeparator /> : null}
</>
) : null}
{canRemoveMember ? (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
data-testid={`sidebar-remove-member-${member.pubkey}`}
disabled={disabled}
onClick={() => onRemoveMember(member)}
>
<Trash2 className="h-4 w-4" />
Remove from channel
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
function getManagedAgentActionIcon(agent: ManagedAgent) {
if (isManagedAgentActive(agent)) {
return <Square className="h-4 w-4" />;
}
if (agent.backend.type === "local" && agent.status === "stopped") {
return <RotateCcw className="h-4 w-4" />;
}
return <Play className="h-4 w-4" />;
}

View File

@@ -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<string | null>(
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<BulkAgentActionResult | undefined>;
actionKey: string;
agents: readonly ManagedAgent[];
failureMessage: string;
onSettled?: () => Promise<void>;
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}`;
}

View File

@@ -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)}

View File

@@ -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;
}

View File

@@ -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<T>(
page: import("@playwright/test").Page,
command: string,
payload?: Record<string, unknown>,
) {
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<string, unknown>;
}) => {
const invoke = (
window as Window & {
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
}
).__SPROUT_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) {
throw new Error("Mock bridge is not installed.");
}
return invoke(command, payload);
},
{ command, payload },
) as Promise<T>;
}
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,61 +809,249 @@ 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 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,
);
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.");
}
const agentPubkey = managedAgentTestId.replace("managed-agent-", "");
await addGenericAgent(page, "general", agentName);
const agentPubkey = await getManagedAgentPubkey(page, agentName);
await page.getByTestId("channel-general").click();
await openMembersSidebar(page, "general");
const removeButton = page.getByTestId(`sidebar-remove-member-${agentPubkey}`);
await expect(removeButton).toBeVisible();
await removeButton.click();
await expect(removeButton).toHaveCount(0);
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__ ?? []
);
});
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.");
}
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);
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);
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 }) => {
await page.goto("/");