mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
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:
@@ -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,
|
||||
}
|
||||
|
||||
197
desktop/src/features/agents/lib/managedAgentControlActions.ts
Normal file
197
desktop/src/features/agents/lib/managedAgentControlActions.ts
Normal 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 {};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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">·</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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
205
desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx
Normal file
205
desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx
Normal 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">·</span>
|
||||
</>
|
||||
) : null}
|
||||
<span>{roleLabel}</span>
|
||||
{managedAgent ? (
|
||||
<>
|
||||
<span aria-hidden="true">·</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" />;
|
||||
}
|
||||
306
desktop/src/features/channels/ui/useMembersSidebarActions.ts
Normal file
306
desktop/src/features/channels/ui/useMembersSidebarActions.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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("/");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user