mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish members sidebar UX and clean up orphaned agents (#252)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -110,22 +110,47 @@ pub(crate) fn process_belongs_to_us(_pid: u32) -> bool {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn terminate_process(pid: u32) -> Result<(), String> {
|
||||
// The child was spawned with process_group(0), so pid == pgid.
|
||||
// Kill the entire process group to avoid orphaning MCP servers
|
||||
// and agent subprocesses.
|
||||
fn signal_process_group_or_leader(pid: u32, signal: i32, action: &str) -> Result<(), String> {
|
||||
let pgid = -(pid as i32);
|
||||
|
||||
// Try graceful shutdown first (SIGTERM to the group).
|
||||
if unsafe { libc::kill(pgid, libc::SIGTERM) } != 0 {
|
||||
// ESRCH means the process is already gone — that's fine.
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.raw_os_error() != Some(libc::ESRCH) && process_is_running(pid) {
|
||||
return Err(format!("failed to terminate process group {pid}: {err}"));
|
||||
}
|
||||
if unsafe { libc::kill(pgid, signal) } == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let group_err = std::io::Error::last_os_error();
|
||||
if !process_is_running(pid) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Some local agent trees can no longer be signalled as a process group
|
||||
// (for example if the leader changed groups, or macOS returns EPERM for one
|
||||
// descendant). Fall back to the leader PID so stop/delete can still recover.
|
||||
if matches!(
|
||||
group_err.raw_os_error(),
|
||||
Some(libc::EPERM) | Some(libc::ESRCH)
|
||||
) {
|
||||
if unsafe { libc::kill(pid as i32, signal) } == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let leader_err = std::io::Error::last_os_error();
|
||||
if leader_err.raw_os_error() == Some(libc::ESRCH) || !process_is_running(pid) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
return Err(format!("failed to {action} process {pid}: {leader_err}"));
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"failed to {action} process group {pid}: {group_err}"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn terminate_process(pid: u32) -> Result<(), String> {
|
||||
// Try graceful shutdown first (SIGTERM to the group).
|
||||
signal_process_group_or_leader(pid, libc::SIGTERM, "terminate")?;
|
||||
|
||||
// Wait up to 1s for graceful exit.
|
||||
for _ in 0..10 {
|
||||
if !process_is_running(pid) {
|
||||
@@ -135,12 +160,7 @@ pub(crate) fn terminate_process(pid: u32) -> Result<(), String> {
|
||||
}
|
||||
|
||||
// Escalate to SIGKILL on the entire group.
|
||||
if unsafe { libc::kill(pgid, libc::SIGKILL) } != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.raw_os_error() != Some(libc::ESRCH) && process_is_running(pid) {
|
||||
return Err(format!("failed to kill process group {pid}: {err}"));
|
||||
}
|
||||
}
|
||||
signal_process_group_or_leader(pid, libc::SIGKILL, "kill")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Best-effort cleanup of managed agents when a channel is deleted.
|
||||
* Best-effort cleanup of channel-scoped managed agents.
|
||||
*
|
||||
* Each agent added via the "Add agents" dialog is a unique process scoped to
|
||||
* the channel. When the channel is deleted these orphaned agents should be
|
||||
* removed — but only if they are not members of any other channel.
|
||||
* Each agent added via the "Add agents" dialog is a dedicated managed-agent
|
||||
* record. If that agent is no longer present in any channel, the managed-agent
|
||||
* record should be removed as well.
|
||||
*/
|
||||
import {
|
||||
deleteManagedAgent,
|
||||
@@ -12,40 +12,66 @@ import {
|
||||
listRelayAgents,
|
||||
} from "@/shared/api/tauri";
|
||||
|
||||
export async function cleanupChannelAgents(channelId: string): Promise<void> {
|
||||
const [members, managedAgents, relayAgents] = await Promise.all([
|
||||
getChannelMembers(channelId),
|
||||
async function cleanupManagedAgentsByPubkey(
|
||||
pubkeys: readonly string[],
|
||||
options?: { ignoreChannelId?: string },
|
||||
): Promise<void> {
|
||||
const normalizedPubkeys = new Set(
|
||||
pubkeys
|
||||
.map((pubkey) => pubkey.trim().toLowerCase())
|
||||
.filter((pubkey) => pubkey.length > 0),
|
||||
);
|
||||
|
||||
if (normalizedPubkeys.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [managedAgents, relayAgents] = await Promise.all([
|
||||
listManagedAgents(),
|
||||
listRelayAgents(),
|
||||
]);
|
||||
|
||||
const memberPubkeys = new Set(
|
||||
members.map((member) => member.pubkey.toLowerCase()),
|
||||
const agentsToDelete = managedAgents.filter((agent) =>
|
||||
normalizedPubkeys.has(agent.pubkey.toLowerCase()),
|
||||
);
|
||||
|
||||
// Find managed agents that are members of this channel.
|
||||
const agentsInChannel = managedAgents.filter((agent) =>
|
||||
memberPubkeys.has(agent.pubkey.toLowerCase()),
|
||||
);
|
||||
|
||||
// Only delete agents that are NOT members of any other channel.
|
||||
const agentsToDelete = agentsInChannel.filter((agent) => {
|
||||
const relayAgent = relayAgents.find(
|
||||
(ra) => ra.pubkey.toLowerCase() === agent.pubkey.toLowerCase(),
|
||||
);
|
||||
if (!relayAgent) {
|
||||
// Not found in relay — safe to delete.
|
||||
return true;
|
||||
}
|
||||
// Only delete if this is the agent's only channel.
|
||||
const otherChannels = relayAgent.channelIds.filter(
|
||||
(id) => id !== channelId,
|
||||
);
|
||||
return otherChannels.length === 0;
|
||||
});
|
||||
|
||||
// Delete orphaned agents (best-effort — don't block channel deletion).
|
||||
await Promise.allSettled(
|
||||
agentsToDelete.map((agent) => deleteManagedAgent(agent.pubkey)),
|
||||
agentsToDelete
|
||||
.filter((agent) => {
|
||||
const relayAgent = relayAgents.find(
|
||||
(candidate) =>
|
||||
candidate.pubkey.toLowerCase() === agent.pubkey.toLowerCase(),
|
||||
);
|
||||
if (!relayAgent) {
|
||||
// Not found in relay — safe to delete.
|
||||
return true;
|
||||
}
|
||||
|
||||
const activeChannelIds = relayAgent.channelIds.filter(
|
||||
(channelId) => channelId !== options?.ignoreChannelId,
|
||||
);
|
||||
return activeChannelIds.length === 0;
|
||||
})
|
||||
.map((agent) => deleteManagedAgent(agent.pubkey)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cleanupManagedAgentIfOrphaned(
|
||||
pubkey: string,
|
||||
channelId?: string,
|
||||
): Promise<void> {
|
||||
await cleanupManagedAgentsByPubkey([pubkey], {
|
||||
ignoreChannelId: channelId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cleanupChannelAgents(channelId: string): Promise<void> {
|
||||
const members = await getChannelMembers(channelId);
|
||||
await cleanupManagedAgentsByPubkey(
|
||||
members.map((member) => member.pubkey),
|
||||
{
|
||||
ignoreChannelId: channelId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
addChannelMembers,
|
||||
@@ -26,7 +21,10 @@ import {
|
||||
unarchiveChannel,
|
||||
updateChannel,
|
||||
} from "@/shared/api/tauri";
|
||||
import { cleanupChannelAgents } from "@/features/channels/cleanupChannelAgents";
|
||||
import {
|
||||
cleanupChannelAgents,
|
||||
cleanupManagedAgentIfOrphaned,
|
||||
} from "@/features/channels/cleanupChannelAgents";
|
||||
import type {
|
||||
AddChannelMembersInput,
|
||||
Channel,
|
||||
@@ -68,68 +66,6 @@ function sortChannels(channels: Channel[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function parseTimestamp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
function isNewerTimestamp(
|
||||
candidate: string | null | undefined,
|
||||
current: string | null | undefined,
|
||||
) {
|
||||
const candidateTimestamp = parseTimestamp(candidate);
|
||||
if (candidateTimestamp === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentTimestamp = parseTimestamp(current);
|
||||
return currentTimestamp === null || candidateTimestamp > currentTimestamp;
|
||||
}
|
||||
|
||||
export function updateChannelLastMessageAt(
|
||||
queryClient: QueryClient,
|
||||
channelId: string,
|
||||
lastMessageAt: string | null | undefined,
|
||||
) {
|
||||
const lastMessageTimestamp = parseTimestamp(lastMessageAt);
|
||||
const normalizedLastMessageAt =
|
||||
lastMessageTimestamp === null
|
||||
? null
|
||||
: new Date(lastMessageTimestamp).toISOString();
|
||||
|
||||
if (!normalizedLastMessageAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
let didUpdate = false;
|
||||
const nextChannels = current.map((channel) => {
|
||||
if (
|
||||
channel.id !== channelId ||
|
||||
!isNewerTimestamp(normalizedLastMessageAt, channel.lastMessageAt)
|
||||
) {
|
||||
return channel;
|
||||
}
|
||||
|
||||
didUpdate = true;
|
||||
return {
|
||||
...channel,
|
||||
lastMessageAt: normalizedLastMessageAt,
|
||||
};
|
||||
});
|
||||
|
||||
return didUpdate ? nextChannels : current;
|
||||
});
|
||||
}
|
||||
|
||||
async function invalidateChannelState(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
channelId: string | null | undefined,
|
||||
@@ -430,9 +366,19 @@ export function useRemoveChannelMemberMutation(channelId: string | null) {
|
||||
}
|
||||
|
||||
await removeChannelMember(channelId, pubkey);
|
||||
|
||||
try {
|
||||
await cleanupManagedAgentIfOrphaned(pubkey, channelId);
|
||||
} catch (error) {
|
||||
console.warn("Failed to clean up managed agent:", error);
|
||||
}
|
||||
},
|
||||
onSettled: async () => {
|
||||
await invalidateChannelState(queryClient, channelId);
|
||||
await Promise.all([
|
||||
invalidateChannelState(queryClient, channelId),
|
||||
queryClient.invalidateQueries({ queryKey: ["managed-agents"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["relay-agents"] }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { channelsQueryKey } from "@/features/channels/hooks";
|
||||
|
||||
function parseTimestamp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
function isNewerTimestamp(
|
||||
candidate: string | null | undefined,
|
||||
current: string | null | undefined,
|
||||
) {
|
||||
const candidateTimestamp = parseTimestamp(candidate);
|
||||
if (candidateTimestamp === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentTimestamp = parseTimestamp(current);
|
||||
return currentTimestamp === null || candidateTimestamp > currentTimestamp;
|
||||
}
|
||||
|
||||
export function updateChannelLastMessageAt(
|
||||
queryClient: QueryClient,
|
||||
channelId: string,
|
||||
lastMessageAt: string | null | undefined,
|
||||
) {
|
||||
const lastMessageTimestamp = parseTimestamp(lastMessageAt);
|
||||
const normalizedLastMessageAt =
|
||||
lastMessageTimestamp === null
|
||||
? null
|
||||
: new Date(lastMessageTimestamp).toISOString();
|
||||
|
||||
if (!normalizedLastMessageAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
let didUpdate = false;
|
||||
const nextChannels = current.map((channel) => {
|
||||
if (
|
||||
channel.id !== channelId ||
|
||||
!isNewerTimestamp(normalizedLastMessageAt, channel.lastMessageAt)
|
||||
) {
|
||||
return channel;
|
||||
}
|
||||
|
||||
didUpdate = true;
|
||||
return {
|
||||
...channel,
|
||||
lastMessageAt: normalizedLastMessageAt,
|
||||
};
|
||||
});
|
||||
|
||||
return didUpdate ? nextChannels : current;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ChannelMember } from "@/shared/api/types";
|
||||
|
||||
export const roleOrder: Record<ChannelMember["role"], number> = {
|
||||
owner: 0,
|
||||
admin: 1,
|
||||
member: 2,
|
||||
guest: 3,
|
||||
bot: 4,
|
||||
};
|
||||
|
||||
export function formatPubkey(pubkey: string) {
|
||||
return `${pubkey.slice(0, 8)}\u2026${pubkey.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function formatMemberName(
|
||||
member: ChannelMember,
|
||||
currentPubkey?: string,
|
||||
) {
|
||||
if (currentPubkey && member.pubkey === currentPubkey) {
|
||||
return "You";
|
||||
}
|
||||
|
||||
return member.displayName ?? formatPubkey(member.pubkey);
|
||||
}
|
||||
|
||||
export function compareMembersByRole(
|
||||
left: ChannelMember,
|
||||
right: ChannelMember,
|
||||
currentPubkey?: string,
|
||||
): number {
|
||||
if (currentPubkey && left.pubkey === currentPubkey) {
|
||||
return -1;
|
||||
}
|
||||
if (currentPubkey && right.pubkey === currentPubkey) {
|
||||
return 1;
|
||||
}
|
||||
const roleDelta = roleOrder[left.role] - roleOrder[right.role];
|
||||
if (roleDelta !== 0) {
|
||||
return roleDelta;
|
||||
}
|
||||
return formatMemberName(left).localeCompare(formatMemberName(right));
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
useManagedAgentsQuery,
|
||||
useRelayAgentsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import type { ChannelMember } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
import { compareMembersByRole } from "./memberUtils";
|
||||
|
||||
export function useClassifiedMembers(
|
||||
members: ChannelMember[],
|
||||
currentPubkey?: string,
|
||||
) {
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const relayAgentsQuery = useRelayAgentsQuery();
|
||||
|
||||
const managedAgents = managedAgentsQuery.data ?? [];
|
||||
const relayAgents = relayAgentsQuery.data ?? [];
|
||||
|
||||
const managedAgentPubkeys = React.useMemo(
|
||||
() => new Set(managedAgents.map((agent) => normalizePubkey(agent.pubkey))),
|
||||
[managedAgents],
|
||||
);
|
||||
const relayAgentPubkeys = React.useMemo(
|
||||
() => new Set(relayAgents.map((agent) => normalizePubkey(agent.pubkey))),
|
||||
[relayAgents],
|
||||
);
|
||||
|
||||
const isBot = React.useCallback(
|
||||
(member: ChannelMember) => {
|
||||
const normalized = normalizePubkey(member.pubkey);
|
||||
return (
|
||||
member.role === "bot" ||
|
||||
managedAgentPubkeys.has(normalized) ||
|
||||
relayAgentPubkeys.has(normalized)
|
||||
);
|
||||
},
|
||||
[managedAgentPubkeys, relayAgentPubkeys],
|
||||
);
|
||||
|
||||
const { people, bots } = React.useMemo(() => {
|
||||
const peopleList: ChannelMember[] = [];
|
||||
const botList: ChannelMember[] = [];
|
||||
|
||||
for (const member of members) {
|
||||
if (isBot(member)) {
|
||||
botList.push(member);
|
||||
} else {
|
||||
peopleList.push(member);
|
||||
}
|
||||
}
|
||||
|
||||
const sort = (list: ChannelMember[]) =>
|
||||
[...list].sort((left, right) =>
|
||||
compareMembersByRole(left, right, currentPubkey),
|
||||
);
|
||||
|
||||
return { people: sort(peopleList), bots: sort(botList) };
|
||||
}, [currentPubkey, isBot, members]);
|
||||
|
||||
return {
|
||||
people,
|
||||
bots,
|
||||
peopleCount: people.length,
|
||||
botCount: bots.length,
|
||||
isBot,
|
||||
managedAgentsQuery,
|
||||
relayAgentsQuery,
|
||||
};
|
||||
}
|
||||
@@ -1,36 +1,30 @@
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Crown,
|
||||
DoorClosed,
|
||||
DoorOpen,
|
||||
FileText,
|
||||
Hash,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Shield,
|
||||
User,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
useAddChannelMembersMutation,
|
||||
useArchiveChannelMutation,
|
||||
useChannelDetailsQuery,
|
||||
useChannelMembersQuery,
|
||||
useDeleteChannelMutation,
|
||||
useJoinChannelMutation,
|
||||
useLeaveChannelMutation,
|
||||
useRemoveChannelMemberMutation,
|
||||
useSetChannelPurposeMutation,
|
||||
useSetChannelTopicMutation,
|
||||
useUnarchiveChannelMutation,
|
||||
useUpdateChannelMutation,
|
||||
} from "@/features/channels/hooks";
|
||||
import { usePresenceQuery } from "@/features/presence/hooks";
|
||||
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
|
||||
import type { Channel, ChannelMember } from "@/shared/api/types";
|
||||
import { compareMembersByRole } from "@/features/channels/lib/memberUtils";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -54,7 +48,6 @@ import {
|
||||
} from "@/shared/ui/sheet";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { ChannelCanvas } from "./ChannelCanvas";
|
||||
import { ChannelMemberInviteCard } from "./ChannelMemberInviteCard";
|
||||
|
||||
type ChannelManagementSheetProps = {
|
||||
channel: Channel | null;
|
||||
@@ -64,26 +57,6 @@ type ChannelManagementSheetProps = {
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
const roleOrder: Record<ChannelMember["role"], number> = {
|
||||
owner: 0,
|
||||
admin: 1,
|
||||
member: 2,
|
||||
guest: 3,
|
||||
bot: 4,
|
||||
};
|
||||
|
||||
function formatPubkey(pubkey: string) {
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`;
|
||||
}
|
||||
|
||||
function formatMemberName(member: ChannelMember, currentPubkey?: string) {
|
||||
if (currentPubkey && member.pubkey === currentPubkey) {
|
||||
return "You";
|
||||
}
|
||||
|
||||
return member.displayName ?? formatPubkey(member.pubkey);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
@@ -120,17 +93,6 @@ function MetadataPill({
|
||||
);
|
||||
}
|
||||
|
||||
function roleIcon(role: ChannelMember["role"]) {
|
||||
switch (role) {
|
||||
case "owner":
|
||||
return Crown;
|
||||
case "admin":
|
||||
return Shield;
|
||||
default:
|
||||
return User;
|
||||
}
|
||||
}
|
||||
|
||||
export function ChannelManagementSheet({
|
||||
channel,
|
||||
currentPubkey,
|
||||
@@ -147,36 +109,16 @@ export function ChannelManagementSheet({
|
||||
const archiveChannelMutation = useArchiveChannelMutation(channelId);
|
||||
const unarchiveChannelMutation = useUnarchiveChannelMutation(channelId);
|
||||
const deleteChannelMutation = useDeleteChannelMutation(channelId);
|
||||
const addMembersMutation = useAddChannelMembersMutation(channelId);
|
||||
const removeMemberMutation = useRemoveChannelMemberMutation(channelId);
|
||||
const joinChannelMutation = useJoinChannelMutation(channelId);
|
||||
const leaveChannelMutation = useLeaveChannelMutation(channelId);
|
||||
|
||||
const detail = detailsQuery.data ?? channel;
|
||||
const members = React.useMemo(() => {
|
||||
const currentMembers = membersQuery.data ?? [];
|
||||
return [...currentMembers].sort((left, right) => {
|
||||
if (currentPubkey && left.pubkey === currentPubkey) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (currentPubkey && right.pubkey === currentPubkey) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const roleDelta = roleOrder[left.role] - roleOrder[right.role];
|
||||
if (roleDelta !== 0) {
|
||||
return roleDelta;
|
||||
}
|
||||
|
||||
return formatMemberName(left).localeCompare(formatMemberName(right));
|
||||
});
|
||||
return [...currentMembers].sort((left, right) =>
|
||||
compareMembersByRole(left, right, currentPubkey),
|
||||
);
|
||||
}, [currentPubkey, membersQuery.data]);
|
||||
const memberPresenceQuery = usePresenceQuery(
|
||||
members.map((member) => member.pubkey),
|
||||
{ enabled: open && members.length > 0 },
|
||||
);
|
||||
|
||||
const selfMember =
|
||||
members.find((member) => member.pubkey === currentPubkey) ?? null;
|
||||
const hasResolvedMembership = membersQuery.data !== undefined;
|
||||
@@ -197,6 +139,11 @@ export function ChannelManagementSheet({
|
||||
detail?.channelType !== "dm" &&
|
||||
!isArchived &&
|
||||
selfMember !== null;
|
||||
const showAccessSection =
|
||||
canJoin ||
|
||||
canLeave ||
|
||||
joinChannelMutation.error instanceof Error ||
|
||||
leaveChannelMutation.error instanceof Error;
|
||||
|
||||
const [nameDraft, setNameDraft] = React.useState("");
|
||||
const [descriptionDraft, setDescriptionDraft] = React.useState("");
|
||||
@@ -312,122 +259,75 @@ export function ChannelManagementSheet({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Section
|
||||
description="Open channels stay visible to everyone. Private channels require an invite."
|
||||
title="Access"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canJoin ? (
|
||||
<Button
|
||||
data-testid="channel-management-join"
|
||||
disabled={joinChannelMutation.isPending}
|
||||
onClick={() => {
|
||||
void joinChannelMutation.mutateAsync();
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<DoorOpen className="h-4 w-4" />
|
||||
{joinChannelMutation.isPending
|
||||
? "Joining..."
|
||||
: "Join channel"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canLeave ? (
|
||||
<Button
|
||||
data-testid="channel-management-leave"
|
||||
disabled={leaveChannelMutation.isPending}
|
||||
onClick={() => {
|
||||
void leaveChannelMutation.mutateAsync().then(() => {
|
||||
onOpenChange(false);
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<DoorClosed className="h-4 w-4" />
|
||||
{leaveChannelMutation.isPending
|
||||
? "Leaving..."
|
||||
: "Leave channel"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{joinChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{joinChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{leaveChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{leaveChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Section
|
||||
description="Name and description are owner/admin actions."
|
||||
title="Details"
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void updateChannelMutation.mutateAsync({
|
||||
description: descriptionDraft.trim() || undefined,
|
||||
name: nameDraft.trim() || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="channel-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
data-testid="channel-management-name"
|
||||
disabled={
|
||||
!canManageChannel || updateChannelMutation.isPending
|
||||
}
|
||||
id="channel-name"
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
value={nameDraft}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="channel-description"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
className="min-h-24"
|
||||
data-testid="channel-management-description"
|
||||
disabled={
|
||||
!canManageChannel || updateChannelMutation.isPending
|
||||
}
|
||||
id="channel-description"
|
||||
onChange={(event) => setDescriptionDraft(event.target.value)}
|
||||
value={descriptionDraft}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="channel-management-save-details"
|
||||
disabled={!canManageChannel || updateChannelMutation.isPending}
|
||||
size="sm"
|
||||
type="submit"
|
||||
{showAccessSection ? (
|
||||
<>
|
||||
<Section
|
||||
description="Open channels stay visible to everyone. Private channels require an invite."
|
||||
title="Access"
|
||||
>
|
||||
{updateChannelMutation.isPending ? "Saving..." : "Save details"}
|
||||
</Button>
|
||||
{updateChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{updateChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canJoin ? (
|
||||
<Button
|
||||
data-testid="channel-management-join"
|
||||
disabled={joinChannelMutation.isPending}
|
||||
onClick={() => {
|
||||
void joinChannelMutation.mutateAsync();
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<DoorOpen className="h-4 w-4" />
|
||||
{joinChannelMutation.isPending
|
||||
? "Joining..."
|
||||
: "Join channel"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canLeave ? (
|
||||
<Button
|
||||
data-testid="channel-management-leave"
|
||||
disabled={leaveChannelMutation.isPending}
|
||||
onClick={() => {
|
||||
void leaveChannelMutation.mutateAsync().then(() => {
|
||||
onOpenChange(false);
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<DoorClosed className="h-4 w-4" />
|
||||
{leaveChannelMutation.isPending
|
||||
? "Leaving..."
|
||||
: "Leave channel"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{joinChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{joinChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{leaveChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{leaveChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Section
|
||||
description="A shared Markdown document for the channel."
|
||||
title="Canvas"
|
||||
>
|
||||
<ChannelCanvas
|
||||
canEdit={canEditNarrative}
|
||||
channelId={channelId}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
@@ -518,112 +418,65 @@ export function ChannelManagementSheet({
|
||||
<Separator />
|
||||
|
||||
<Section
|
||||
description="A shared Markdown document for the channel."
|
||||
title="Canvas"
|
||||
description="Name and description are owner/admin actions."
|
||||
title="Details"
|
||||
>
|
||||
<ChannelCanvas
|
||||
canEdit={canEditNarrative}
|
||||
channelId={channelId}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Section
|
||||
description="Owners and admins can invite members or remove them."
|
||||
title="Members"
|
||||
>
|
||||
{canManageChannel && resolvedChannel.channelType !== "dm" ? (
|
||||
<ChannelMemberInviteCard
|
||||
existingMembers={members}
|
||||
isPending={addMembersMutation.isPending}
|
||||
onSubmit={(input) => addMembersMutation.mutateAsync(input)}
|
||||
open={open}
|
||||
requestErrorMessage={
|
||||
addMembersMutation.error instanceof Error
|
||||
? addMembersMutation.error.message
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2" data-testid="channel-members-list">
|
||||
{members.length > 0 ? (
|
||||
members.map((member) => {
|
||||
const Icon = roleIcon(member.role);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start justify-between gap-3 rounded-2xl border border-border/80 bg-background px-4 py-3"
|
||||
data-testid={`channel-member-${member.pubkey}`}
|
||||
key={member.pubkey}
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="truncate text-sm font-medium">
|
||||
{formatMemberName(member, currentPubkey)}
|
||||
</p>
|
||||
{memberPresenceQuery.data ? (
|
||||
<PresenceBadge
|
||||
className="border-border/70 bg-muted/50 px-2 py-0.5 text-[10px] uppercase tracking-[0.14em]"
|
||||
data-testid={`member-presence-${member.pubkey}`}
|
||||
status={
|
||||
memberPresenceQuery.data[
|
||||
member.pubkey.toLowerCase()
|
||||
] ?? "offline"
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{member.role}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{member.pubkey}
|
||||
</p>
|
||||
</div>
|
||||
{canManageChannel ||
|
||||
(currentPubkey && member.pubkey === currentPubkey) ? (
|
||||
<Button
|
||||
data-testid={`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>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{membersQuery.isLoading
|
||||
? "Loading members..."
|
||||
: "No active members found."}
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void updateChannelMutation.mutateAsync({
|
||||
description: descriptionDraft.trim() || undefined,
|
||||
name: nameDraft.trim() || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="channel-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
data-testid="channel-management-name"
|
||||
disabled={
|
||||
!canManageChannel || updateChannelMutation.isPending
|
||||
}
|
||||
id="channel-name"
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
value={nameDraft}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="channel-description"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
className="min-h-24"
|
||||
data-testid="channel-management-description"
|
||||
disabled={
|
||||
!canManageChannel || updateChannelMutation.isPending
|
||||
}
|
||||
id="channel-description"
|
||||
onChange={(event) => setDescriptionDraft(event.target.value)}
|
||||
value={descriptionDraft}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="channel-management-save-details"
|
||||
disabled={!canManageChannel || updateChannelMutation.isPending}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
{updateChannelMutation.isPending ? "Saving..." : "Save details"}
|
||||
</Button>
|
||||
{updateChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{updateChannelMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{removeMemberMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{removeMemberMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
) : null}
|
||||
</form>
|
||||
</Section>
|
||||
|
||||
{resolvedChannel.channelType !== "dm" ? (
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { Search, UserPlus, X } from "lucide-react";
|
||||
import { ChevronDown, Search, UserPlus, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { formatPubkey } from "@/features/channels/lib/memberUtils";
|
||||
import { useUserSearchQuery } from "@/features/profile/hooks";
|
||||
import type {
|
||||
AddChannelMembersResult,
|
||||
ChannelMember,
|
||||
UserSearchResult,
|
||||
} from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
|
||||
function formatPubkey(pubkey: string) {
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`;
|
||||
}
|
||||
|
||||
function formatSearchUserName(user: UserSearchResult) {
|
||||
return (
|
||||
user.displayName?.trim() ||
|
||||
@@ -52,6 +50,8 @@ export function ChannelMemberInviteCard({
|
||||
}) {
|
||||
const [invitePubkeys, setInvitePubkeys] = React.useState("");
|
||||
const [inviteQuery, setInviteQuery] = React.useState("");
|
||||
const [isDirectPubkeyEntryOpen, setIsDirectPubkeyEntryOpen] =
|
||||
React.useState(false);
|
||||
const [selectedInvitees, setSelectedInvitees] = React.useState<
|
||||
UserSearchResult[]
|
||||
>([]);
|
||||
@@ -89,25 +89,34 @@ export function ChannelMemberInviteCard({
|
||||
if (!open) {
|
||||
setInvitePubkeys("");
|
||||
setInviteQuery("");
|
||||
setIsDirectPubkeyEntryOpen(false);
|
||||
setSelectedInvitees([]);
|
||||
setSubmissionErrors([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const parsedInvitePubkeys = invitePubkeys
|
||||
.split(/[\s,]+/)
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
const parsedInvitePubkeys = React.useMemo(
|
||||
() =>
|
||||
invitePubkeys
|
||||
.split(/[\s,]+/)
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
[invitePubkeys],
|
||||
);
|
||||
const inviteTargets = [
|
||||
...new Set([
|
||||
...selectedInvitees.map((invitee) => invitee.pubkey),
|
||||
...parsedInvitePubkeys,
|
||||
]),
|
||||
];
|
||||
const directEntryLabel =
|
||||
parsedInvitePubkeys.length > 0 && !isDirectPubkeyEntryOpen
|
||||
? `Direct pubkey entry (${parsedInvitePubkeys.length} ready)`
|
||||
: "Direct pubkey entry";
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-3 rounded-2xl border border-border/80 bg-muted/20 p-4"
|
||||
className="space-y-2.5 rounded-xl border border-border/80 bg-muted/15 p-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit({
|
||||
@@ -122,29 +131,35 @@ export function ChannelMemberInviteCard({
|
||||
(invitee) => !addedPubkeys.has(invitee.pubkey.toLowerCase()),
|
||||
),
|
||||
);
|
||||
setInvitePubkeys(
|
||||
parsedInvitePubkeys
|
||||
.filter((pubkey) => !addedPubkeys.has(pubkey.toLowerCase()))
|
||||
.join("\n"),
|
||||
);
|
||||
const remainingPubkeys = parsedInvitePubkeys
|
||||
.filter((pubkey) => !addedPubkeys.has(pubkey.toLowerCase()))
|
||||
.join("\n");
|
||||
setInvitePubkeys(remainingPubkeys);
|
||||
if (remainingPubkeys.length > 0) {
|
||||
setIsDirectPubkeyEntryOpen(true);
|
||||
}
|
||||
setInviteQuery("");
|
||||
setSubmissionErrors(result.errors);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Add members
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
<span>Add members</span>
|
||||
</div>
|
||||
{inviteTargets.length > 0 ? (
|
||||
<span className="rounded-full bg-background px-2 py-1 text-[11px] font-medium leading-none text-muted-foreground">
|
||||
{inviteTargets.length} selected
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="channel-management-search-users"
|
||||
>
|
||||
<label className="sr-only" htmlFor="channel-management-search-users">
|
||||
Search people
|
||||
</label>
|
||||
<div className="rounded-xl border border-border/80 bg-background">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="rounded-lg border border-border/80 bg-background">
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-auto border-0 px-0 py-0 shadow-none focus-visible:ring-0"
|
||||
@@ -152,15 +167,15 @@ export function ChannelMemberInviteCard({
|
||||
disabled={isPending}
|
||||
id="channel-management-search-users"
|
||||
onChange={(event) => setInviteQuery(event.target.value)}
|
||||
placeholder="Search by name, NIP-05, or pubkey."
|
||||
placeholder="Search by name or NIP-05."
|
||||
value={inviteQuery}
|
||||
/>
|
||||
</div>
|
||||
{selectedInvitees.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 border-t border-border/70 px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1.5 border-t border-border/70 px-2.5 py-2">
|
||||
{selectedInvitees.map((invitee) => (
|
||||
<div
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border/80 bg-muted/60 px-3 py-1 text-xs"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-border/80 bg-muted/60 px-2.5 py-1 text-[11px] leading-none"
|
||||
data-testid={`selected-invitee-${invitee.pubkey}`}
|
||||
key={invitee.pubkey}
|
||||
>
|
||||
@@ -179,7 +194,7 @@ export function ChannelMemberInviteCard({
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -192,10 +207,10 @@ export function ChannelMemberInviteCard({
|
||||
Searching…
|
||||
</p>
|
||||
) : inviteSearchResults.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="max-h-44 space-y-1 overflow-y-auto">
|
||||
{inviteSearchResults.map((result) => (
|
||||
<button
|
||||
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-left transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
data-testid={`channel-user-search-result-${result.pubkey}`}
|
||||
key={result.pubkey}
|
||||
onClick={() => {
|
||||
@@ -205,7 +220,7 @@ export function ChannelMemberInviteCard({
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
<p className="truncate text-sm font-medium leading-5">
|
||||
{formatSearchUserName(result)}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
@@ -230,49 +245,76 @@ export function ChannelMemberInviteCard({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="channel-management-add-pubkeys"
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
aria-controls="channel-management-direct-pubkeys-panel"
|
||||
aria-expanded={isDirectPubkeyEntryOpen}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
data-testid="channel-management-toggle-direct-pubkeys"
|
||||
onClick={() => {
|
||||
setIsDirectPubkeyEntryOpen((current) => !current);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Paste pubkeys
|
||||
</label>
|
||||
<Textarea
|
||||
className="min-h-24"
|
||||
data-testid="channel-management-add-pubkeys"
|
||||
disabled={isPending}
|
||||
id="channel-management-add-pubkeys"
|
||||
onChange={(event) => setInvitePubkeys(event.target.value)}
|
||||
placeholder="Optional: paste one or more pubkeys, separated by spaces, commas, or new lines."
|
||||
value={invitePubkeys}
|
||||
/>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform",
|
||||
isDirectPubkeyEntryOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<span>{directEntryLabel}</span>
|
||||
</button>
|
||||
|
||||
{isDirectPubkeyEntryOpen ? (
|
||||
<div
|
||||
className="space-y-1.5 rounded-lg border border-dashed border-border/80 bg-background/70 p-2.5"
|
||||
id="channel-management-direct-pubkeys-panel"
|
||||
>
|
||||
<label className="sr-only" htmlFor="channel-management-add-pubkeys">
|
||||
Paste pubkeys
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For exact pubkeys when search is not the right fit.
|
||||
</p>
|
||||
<Textarea
|
||||
className="min-h-24"
|
||||
data-testid="channel-management-add-pubkeys"
|
||||
disabled={isPending}
|
||||
id="channel-management-add-pubkeys"
|
||||
onChange={(event) => setInvitePubkeys(event.target.value)}
|
||||
placeholder="Paste one or more pubkeys, separated by spaces, commas, or new lines."
|
||||
value={invitePubkeys}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
htmlFor="channel-member-role"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label className="sr-only" htmlFor="channel-member-role">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
data-testid="channel-management-add-role"
|
||||
disabled={isPending}
|
||||
id="channel-member-role"
|
||||
onChange={(event) =>
|
||||
setInviteRole(
|
||||
event.target.value as Exclude<ChannelMember["role"], "owner">,
|
||||
)
|
||||
}
|
||||
value={inviteRole}
|
||||
>
|
||||
{["member", "admin", "guest", "bot"].map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Role</span>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm"
|
||||
data-testid="channel-management-add-role"
|
||||
disabled={isPending}
|
||||
id="channel-member-role"
|
||||
onChange={(event) =>
|
||||
setInviteRole(
|
||||
event.target.value as Exclude<ChannelMember["role"], "owner">,
|
||||
)
|
||||
}
|
||||
value={inviteRole}
|
||||
>
|
||||
{["member", "admin", "guest", "bot"].map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
className="min-w-24"
|
||||
data-testid="channel-management-add-members"
|
||||
disabled={isPending || inviteTargets.length === 0}
|
||||
size="sm"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bot, Plus, Settings2, UserRound, Zap } from "lucide-react";
|
||||
import { Plus, Settings2, Users, Zap } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
@@ -18,34 +18,14 @@ type ChannelMembersBarProps = {
|
||||
channel: Channel;
|
||||
currentPubkey?: string;
|
||||
onManageChannel: () => void;
|
||||
onToggleMembers: () => void;
|
||||
};
|
||||
|
||||
function CountStat({
|
||||
count,
|
||||
icon: Icon,
|
||||
label,
|
||||
loading,
|
||||
}: {
|
||||
count: number;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex h-8 items-center justify-center gap-1.5 rounded-full bg-muted/55 px-2.5 text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="relative top-px min-w-[1ch] text-[13px] font-medium leading-none text-muted-foreground/80 tabular-nums">
|
||||
{loading ? "..." : count}
|
||||
</span>
|
||||
<span className="sr-only">{loading ? `Loading ${label}` : label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelMembersBar({
|
||||
channel,
|
||||
currentPubkey,
|
||||
onManageChannel,
|
||||
onToggleMembers,
|
||||
}: ChannelMembersBarProps) {
|
||||
const [isAddBotOpen, setIsAddBotOpen] = React.useState(false);
|
||||
const [isCreateWorkflowOpen, setIsCreateWorkflowOpen] = React.useState(false);
|
||||
@@ -55,6 +35,7 @@ export function ChannelMembersBar({
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const relayAgentsQuery = useRelayAgentsQuery();
|
||||
const members = membersQuery.data ?? [];
|
||||
const memberCount = membersQuery.data?.length ?? channel.memberCount;
|
||||
const providers = React.useMemo(
|
||||
() =>
|
||||
[...(providersQuery.data ?? [])].sort((left, right) => {
|
||||
@@ -68,8 +49,6 @@ export function ChannelMembersBar({
|
||||
}),
|
||||
[providersQuery.data],
|
||||
);
|
||||
const managedAgents = managedAgentsQuery.data ?? [];
|
||||
const relayAgents = relayAgentsQuery.data ?? [];
|
||||
const normalizedCurrentPubkey = currentPubkey
|
||||
? normalizePubkey(currentPubkey)
|
||||
: null;
|
||||
@@ -83,27 +62,6 @@ export function ChannelMembersBar({
|
||||
channel.channelType !== "dm" &&
|
||||
channel.archivedAt === null &&
|
||||
(channel.visibility === "open" || canManageMembers);
|
||||
const managedAgentPubkeys = React.useMemo(
|
||||
() => new Set(managedAgents.map((agent) => normalizePubkey(agent.pubkey))),
|
||||
[managedAgents],
|
||||
);
|
||||
const relayAgentPubkeys = React.useMemo(
|
||||
() => new Set(relayAgents.map((agent) => normalizePubkey(agent.pubkey))),
|
||||
[relayAgents],
|
||||
);
|
||||
const peopleCount = React.useMemo(
|
||||
() =>
|
||||
members.filter((member) => {
|
||||
const normalizedPubkey = normalizePubkey(member.pubkey);
|
||||
return (
|
||||
member.role !== "bot" &&
|
||||
!managedAgentPubkeys.has(normalizedPubkey) &&
|
||||
!relayAgentPubkeys.has(normalizedPubkey)
|
||||
);
|
||||
}).length,
|
||||
[managedAgentPubkeys, members, relayAgentPubkeys],
|
||||
);
|
||||
const botCount = members.length - peopleCount;
|
||||
const previousChannelIdRef = React.useRef(channel.id);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -128,20 +86,6 @@ export function ChannelMembersBar({
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="flex items-center gap-2">
|
||||
<CountStat
|
||||
count={peopleCount}
|
||||
icon={UserRound}
|
||||
label="people"
|
||||
loading={membersQuery.isLoading}
|
||||
/>
|
||||
|
||||
<CountStat
|
||||
count={botCount}
|
||||
icon={Bot}
|
||||
label="bots"
|
||||
loading={membersQuery.isLoading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
aria-label="Add agent"
|
||||
className="h-9 w-9 rounded-full"
|
||||
@@ -172,6 +116,20 @@ export function ChannelMembersBar({
|
||||
<Zap className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
aria-label={`View channel members (${memberCount})`}
|
||||
className="h-9 gap-1.5 rounded-full px-3"
|
||||
data-testid="channel-members-trigger"
|
||||
onClick={onToggleMembers}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
<span className="min-w-[1ch] text-sm font-medium tabular-nums">
|
||||
{memberCount}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
aria-label="Manage channel"
|
||||
className="h-9 w-9 rounded-full"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||
import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
|
||||
import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers";
|
||||
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
|
||||
import { MembersSidebar } from "@/features/channels/ui/MembersSidebar";
|
||||
import {
|
||||
mergeMessages,
|
||||
useChannelMessagesQuery,
|
||||
@@ -75,6 +76,7 @@ export function ChannelScreen({
|
||||
searchAnchorEvent,
|
||||
}: ChannelScreenProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false);
|
||||
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
|
||||
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
|
||||
const currentPubkey = currentIdentity?.pubkey;
|
||||
@@ -348,6 +350,7 @@ export function ChannelScreen({
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
onManageChannel={onManageChannel}
|
||||
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
@@ -427,6 +430,13 @@ export function ChannelScreen({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MembersSidebar
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
open={isMembersSidebarOpen}
|
||||
onOpenChange={setIsMembersSidebarOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
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 {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/shared/ui/sheet";
|
||||
import { ChannelMemberInviteCard } from "./ChannelMemberInviteCard";
|
||||
|
||||
type MembersSidebarProps = {
|
||||
channel: Channel | null;
|
||||
currentPubkey?: string;
|
||||
open: boolean;
|
||||
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,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: MembersSidebarProps) {
|
||||
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 } = useClassifiedMembers(
|
||||
rawMembers,
|
||||
currentPubkey,
|
||||
);
|
||||
|
||||
const allMemberPubkeys = React.useMemo(
|
||||
() => rawMembers.map((member) => member.pubkey),
|
||||
[rawMembers],
|
||||
);
|
||||
const memberPresenceQuery = usePresenceQuery(allMemberPubkeys, {
|
||||
enabled: open && rawMembers.length > 0,
|
||||
});
|
||||
const memberProfilesQuery = useUsersBatchQuery(allMemberPubkeys, {
|
||||
enabled: open && rawMembers.length > 0,
|
||||
});
|
||||
|
||||
const selfMember =
|
||||
rawMembers.find((member) => member.pubkey === currentPubkey) ?? null;
|
||||
const canManageMembers =
|
||||
selfMember?.role === "owner" || selfMember?.role === "admin";
|
||||
const isArchived =
|
||||
channel?.archivedAt !== null && channel?.archivedAt !== undefined;
|
||||
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderMemberCard(member: ChannelMember, memberIsBot: boolean) {
|
||||
const canRemoveMember =
|
||||
(selfMember?.role === "admin" && member.pubkey !== currentPubkey) ||
|
||||
(selfMember?.role === "owner" && isBot(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}`}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={onOpenChange} open={open}>
|
||||
<SheetContent
|
||||
className="flex w-full flex-col gap-0 overflow-hidden border-l border-border/80 bg-background p-0 sm:max-w-md"
|
||||
data-testid="members-sidebar"
|
||||
side="right"
|
||||
>
|
||||
<SheetHeader className="space-y-2 border-b border-border/80 bg-muted/20 px-6 py-6 text-left">
|
||||
<SheetTitle>Members</SheetTitle>
|
||||
<SheetDescription>
|
||||
People and bots in {channel.name}.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
|
||||
{(canManageMembers || channel.visibility === "open") &&
|
||||
channel.channelType !== "dm" ? (
|
||||
<ChannelMemberInviteCard
|
||||
existingMembers={rawMembers}
|
||||
isPending={addMembersMutation.isPending}
|
||||
onSubmit={(input) => addMembersMutation.mutateAsync(input)}
|
||||
open={open}
|
||||
requestErrorMessage={
|
||||
addMembersMutation.error instanceof Error
|
||||
? addMembersMutation.error.message
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold tracking-tight">People</h2>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
{people.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2" data-testid="members-sidebar-people">
|
||||
{people.length > 0 ? (
|
||||
people.map((member) => renderMemberCard(member, false))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{membersQuery.isLoading
|
||||
? "Loading members..."
|
||||
: "No people found."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2.5">
|
||||
<div className="flex items-center justify-between 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>
|
||||
</div>
|
||||
<div className="space-y-2" data-testid="members-sidebar-bots">
|
||||
{bots.length > 0 ? (
|
||||
bots.map((member) => renderMemberCard(member, true))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{membersQuery.isLoading
|
||||
? "Loading members..."
|
||||
: "No bots found."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{removeMemberMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{removeMemberMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
channelsQueryKey,
|
||||
updateChannelLastMessageAt,
|
||||
} from "@/features/channels/hooks";
|
||||
import { updateChannelLastMessageAt } from "@/features/channels/lib/channelCache";
|
||||
import { channelsQueryKey } from "@/features/channels/hooks";
|
||||
import { mergeTimelineCacheMessages } from "@/features/messages/hooks";
|
||||
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
|
||||
import { getChannelIdFromTags } from "@/features/messages/lib/threading";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useEffectEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { updateChannelLastMessageAt } from "@/features/channels/hooks";
|
||||
import { updateChannelLastMessageAt } from "@/features/channels/lib/channelCache";
|
||||
import {
|
||||
channelMessagesKey,
|
||||
dedupeMessagesById,
|
||||
|
||||
@@ -19,6 +19,16 @@ async function closeChannelManagement(page: import("@playwright/test").Page) {
|
||||
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function openMembersSidebar(
|
||||
page: import("@playwright/test").Page,
|
||||
channelName: string,
|
||||
) {
|
||||
await page.getByTestId(`channel-${channelName}`).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
await page.getByTestId("channel-members-trigger").click();
|
||||
await expect(page.getByTestId("members-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
@@ -63,14 +73,15 @@ test("shows presence in sidebar, DM header, and member list", async ({
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
|
||||
await expect(page.getByTestId("chat-presence-badge")).toContainText("Online");
|
||||
|
||||
await openChannelManagement(page, "general");
|
||||
await openMembersSidebar(page, "general");
|
||||
await expect(
|
||||
page.getByTestId(`member-presence-${TEST_IDENTITIES.alice.pubkey}`),
|
||||
page.getByTestId(`sidebar-member-presence-${TEST_IDENTITIES.alice.pubkey}`),
|
||||
).toContainText("Online");
|
||||
await expect(
|
||||
page.getByTestId(`member-presence-${TEST_IDENTITIES.bob.pubkey}`),
|
||||
page.getByTestId(`sidebar-member-presence-${TEST_IDENTITIES.bob.pubkey}`),
|
||||
).toContainText("Away");
|
||||
await closeChannelManagement(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("members-sidebar")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("start a new direct message from the sidebar", async ({ page }) => {
|
||||
@@ -342,10 +353,35 @@ test("manage channel updates details and context", async ({ page }) => {
|
||||
);
|
||||
});
|
||||
|
||||
test("manage channel can invite and remove members", async ({ page }) => {
|
||||
test("manage channel keeps canvas near the top of the sheet", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await openChannelManagement(page, "general");
|
||||
|
||||
const sectionHeadings = await page
|
||||
.getByTestId("channel-management-sheet")
|
||||
.locator("section h2")
|
||||
.allTextContents();
|
||||
|
||||
expect(sectionHeadings).toEqual([
|
||||
"Access",
|
||||
"Canvas",
|
||||
"Context",
|
||||
"Details",
|
||||
"Channel state",
|
||||
"Danger zone",
|
||||
]);
|
||||
});
|
||||
|
||||
test("members sidebar can invite and remove members", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await openMembersSidebar(page, "general");
|
||||
await expect(page.getByTestId("channel-members-trigger")).toContainText("3");
|
||||
await expect(page.getByTestId("channel-management-add-pubkeys")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await page.getByTestId("channel-management-search-users").fill("char");
|
||||
await expect(
|
||||
page.getByTestId(
|
||||
@@ -369,19 +405,44 @@ test("manage channel can invite and remove members", async ({ page }) => {
|
||||
"",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId(`channel-member-${TEST_IDENTITIES.charlie.pubkey}`),
|
||||
page.getByTestId(`sidebar-member-${TEST_IDENTITIES.charlie.pubkey}`),
|
||||
).toContainText("charlie");
|
||||
await expect(
|
||||
page.getByTestId(`channel-member-${TEST_IDENTITIES.charlie.pubkey}`),
|
||||
).toContainText("admin");
|
||||
await expect(page.getByTestId("channel-members-trigger")).toContainText("4");
|
||||
|
||||
await page
|
||||
.getByTestId(`remove-member-${TEST_IDENTITIES.charlie.pubkey}`)
|
||||
.getByTestId(`sidebar-remove-member-${TEST_IDENTITIES.charlie.pubkey}`)
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`channel-member-${TEST_IDENTITIES.charlie.pubkey}`),
|
||||
page.getByTestId(`sidebar-member-${TEST_IDENTITIES.charlie.pubkey}`),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("channel-members-trigger")).toContainText("3");
|
||||
});
|
||||
|
||||
test("members sidebar keeps direct pubkey entry behind a toggle", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await openMembersSidebar(page, "general");
|
||||
|
||||
await expect(page.getByTestId("channel-management-add-pubkeys")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await page.getByTestId("channel-management-toggle-direct-pubkeys").click();
|
||||
await expect(
|
||||
page.getByTestId("channel-management-add-pubkeys"),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByTestId("channel-management-add-pubkeys")
|
||||
.fill(TEST_IDENTITIES.outsider.pubkey);
|
||||
await page.getByTestId("channel-management-add-members").click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-member-${TEST_IDENTITIES.outsider.pubkey}`),
|
||||
).toContainText("outsider");
|
||||
await expect(page.getByTestId("channel-members-trigger")).toContainText("4");
|
||||
});
|
||||
|
||||
test("open-channel members can add agents from the header", async ({
|
||||
@@ -399,6 +460,67 @@ test("open-channel members can add agents from the header", async ({
|
||||
await expect(page.getByRole("heading", { name: "Add agents" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("removing a channel-scoped agent also cleans up the managed agent record", async ({
|
||||
page,
|
||||
}) => {
|
||||
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 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 page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("members-sidebar")).not.toBeVisible();
|
||||
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await expect(page.getByTestId(`managed-agent-${agentPubkey}`)).toHaveCount(0);
|
||||
|
||||
const commands = await page.evaluate(() => {
|
||||
return (
|
||||
(
|
||||
window as Window & {
|
||||
__SPROUT_E2E_COMMANDS__?: string[];
|
||||
}
|
||||
).__SPROUT_E2E_COMMANDS__ ?? []
|
||||
);
|
||||
});
|
||||
expect(commands).toContain("delete_managed_agent");
|
||||
});
|
||||
|
||||
test("open channel management supports join and leave", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
@@ -411,14 +533,19 @@ test("open channel management supports join and leave", async ({ page }) => {
|
||||
.click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("design");
|
||||
|
||||
// Open members sidebar — should show current user after joining
|
||||
await page.getByTestId("channel-members-trigger").click();
|
||||
await expect(page.getByTestId("members-sidebar")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-member-${MOCK_IDENTITY_PUBKEY}`),
|
||||
).toContainText("You");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Open channel management — should show Leave since we just joined
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
|
||||
await expect(page.getByTestId("channel-management-join")).toHaveCount(0);
|
||||
await expect(page.getByTestId("channel-management-leave")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId(`channel-member-${MOCK_IDENTITY_PUBKEY}`),
|
||||
).toContainText("You");
|
||||
|
||||
// Leave the channel
|
||||
await page.getByTestId("channel-management-leave").click();
|
||||
|
||||
Reference in New Issue
Block a user