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:
Wes
2026-04-06 15:43:51 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent c0421a50ed
commit 335a426982
14 changed files with 934 additions and 548 deletions
+37 -17
View File
@@ -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,
},
);
}
+16 -70
View File
@@ -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">&middot;</span>
</>
) : null}
<span>{roleLabel}</span>
</div>
</div>
</div>
{canRemoveMember ? (
<Button
className="h-8 shrink-0 rounded-full px-2.5 text-xs text-muted-foreground hover:text-foreground"
data-testid={`sidebar-remove-member-${member.pubkey}`}
disabled={removeMemberMutation.isPending || isArchived}
onClick={() => {
void removeMemberMutation.mutateAsync(member.pubkey).then(() => {
if (member.pubkey === currentPubkey) {
onOpenChange(false);
}
});
}}
size="sm"
type="button"
variant="ghost"
>
Remove
</Button>
) : null}
</div>
);
}
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 -1
View File
@@ -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,
+141 -14
View File
@@ -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();