From 4bed899be190dd527b054282326414de4cd7a51a Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Mon, 13 Jul 2026 17:45:20 -0700 Subject: [PATCH 1/6] feat(desktop): add channel management menu actions Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- desktop/src/app/AppShell.tsx | 12 ++- desktop/src/app/AppShellContext.tsx | 5 +- desktop/src/app/AppShellOverlays.tsx | 3 + .../channels/ui/ChannelManagementSheet.tsx | 5 +- .../sidebar/ui/ChannelContextMenu.tsx | 79 +++++++++++++++++++ desktop/tests/e2e/channels.spec.ts | 69 ++++++++++++++++ 6 files changed, 170 insertions(+), 3 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 905235aa88..1b12c7eeb4 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -106,6 +106,8 @@ export function AppShell() { const [managedChannelId, setManagedChannelId] = React.useState( null, ); + const [openChannelManagementInEditMode, setOpenChannelManagementInEditMode] = + React.useState(false); const [searchFocusRequest, setSearchFocusRequest] = React.useState(0); const [browseDialogType, setBrowseDialogType] = React.useState(null); @@ -637,10 +639,14 @@ export function AppShell() { markChannelRead, markChannelUnread, openCreateChannel: handleOpenCreateChannel, - openChannelManagement: (channelId?: string) => { + openChannelManagement: ( + channelId?: string, + options?: { edit?: boolean }, + ) => { setManagedChannelId( typeof channelId === "string" ? channelId : null, ); + setOpenChannelManagementInEditMode(options?.edit === true); setIsChannelManagementOpen(true); }, getChannelReadAt, @@ -906,12 +912,16 @@ export function AppShell() { channels={channels} currentPubkey={identityQuery.data?.pubkey} isChannelManagementOpen={isChannelManagementOpen} + openChannelManagementInEditMode={ + openChannelManagementInEditMode + } onBrowseChannelJoin={handleBrowseChannelJoin} onBrowseDialogOpenChange={handleBrowseDialogOpenChange} onChannelManagementOpenChange={(open) => { setIsChannelManagementOpen(open); if (!open) { setManagedChannelId(null); + setOpenChannelManagementInEditMode(false); } }} onDeleteActiveChannel={() => { diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index 30d67fd56b..1ef2893101 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -16,7 +16,10 @@ type AppShellContextValue = { ) => void; markChannelUnread: (channelId: string) => void; openCreateChannel: () => void; - openChannelManagement: (channelId?: string) => void; + openChannelManagement: ( + channelId?: string, + options?: { edit?: boolean }, + ) => void; // NIP-RS read marker for a channel as a unix-seconds timestamp, or null // when unknown. Backed by the single AppShell-mounted ReadStateManager so // every surface (sidebar, home, badges) projects from the same source. diff --git a/desktop/src/app/AppShellOverlays.tsx b/desktop/src/app/AppShellOverlays.tsx index 514492a296..ebb9bb3da4 100644 --- a/desktop/src/app/AppShellOverlays.tsx +++ b/desktop/src/app/AppShellOverlays.tsx @@ -21,6 +21,7 @@ type AppShellOverlaysProps = { channels: Channel[]; currentPubkey?: string; isChannelManagementOpen: boolean; + openChannelManagementInEditMode: boolean; onBrowseChannelJoin: (channelId: string) => Promise; onBrowseDialogOpenChange: (open: boolean) => void; onChannelManagementOpenChange: (open: boolean) => void; @@ -34,6 +35,7 @@ export function AppShellOverlays({ channels, currentPubkey, isChannelManagementOpen, + openChannelManagementInEditMode, onBrowseChannelJoin, onBrowseDialogOpenChange, onChannelManagementOpenChange, @@ -80,6 +82,7 @@ export function AppShellOverlays({ void; onOpenChange: (open: boolean) => void; @@ -100,6 +101,7 @@ export function ChannelManagementSheet({ animateSplitEnter = false, channel, currentPubkey, + initiallyEditing = false, layout = "overlay", onDeleted, onOpenChange, @@ -229,8 +231,9 @@ export function ChannelManagementSheet({ setTtlDraft( detail.ttlSeconds !== null ? formatTtlDuration(detail.ttlSeconds) : "", ); + setIsEditDialogOpen(initiallyEditing); setActiveView("summary"); - }, [detail, open]); + }, [detail, initiallyEditing, open]); if (!channel) { return null; diff --git a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx index b965aed086..1f6777f155 100644 --- a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx +++ b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx @@ -1,4 +1,5 @@ import { + Archive, Bell, BellOff, Check, @@ -6,11 +7,22 @@ import { CircleDot, Copy, LogOut, + Pencil, Plus, Star, StarOff, } from "lucide-react"; +import { toast } from "sonner"; +import { useAppShell } from "@/app/AppShellContext"; +import { + useArchiveChannelMutation, + useChannelMembersQuery, +} from "@/features/channels/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections"; import { ContextMenuIconSlot, @@ -166,6 +178,37 @@ export function ChannelContextMenuItems({ onCreateSectionForChannel?: (channelId: string) => void; onLeaveChannel?: (channel: Channel) => void; }) { + const { openChannelManagement } = useAppShell(); + const currentPubkey = useIdentityQuery().data?.pubkey; + const archiveChannelMutation = useArchiveChannelMutation(channel.id); + const membersQuery = useChannelMembersQuery( + channel.id, + channel.channelType !== "dm", + ); + const ownerPubkeys = + membersQuery.data + ?.filter( + (member) => member.role === "owner" && member.pubkey !== currentPubkey, + ) + .map((member) => member.pubkey) ?? []; + const ownerProfilesQuery = useUsersBatchQuery(ownerPubkeys, { + enabled: ownerPubkeys.length > 0, + }); + const selfMember = membersQuery.data?.find( + (member) => member.pubkey === currentPubkey, + ); + const canManageOwnedAgentChannel = ownerPubkeys.some((pubkey) => + ownsAuthorAgent( + ownerProfilesQuery.data?.profiles[normalizePubkey(pubkey)], + currentPubkey, + ), + ); + const showManagementActions = + channel.channelType !== "dm" && + channel.archivedAt === null && + (selfMember?.role === "owner" || + selfMember?.role === "admin" || + canManageOwnedAgentChannel); const showStar = Boolean(onStarChannel && onUnstarChannel); const showReadToggle = hasUnread ? Boolean(onMarkChannelRead) @@ -192,6 +235,42 @@ export function ChannelContextMenuItems({ onCreateSectionForChannel={onCreateSectionForChannel ?? (() => {})} /> ) : null} + {showManagementActions ? ( + <> + + + deferMenuAction(() => + openChannelManagement(channel.id, { edit: true }), + ) + } + > + + + + Edit channel + + + deferMenuAction(() => { + void archiveChannelMutation.mutateAsync().catch((error) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to archive channel.", + ); + }); + }) + } + > + + + + Archive channel + + + ) : null} {showReadToggle ? : null} {hasUnread && onMarkChannelRead ? ( { ).toBeVisible(); }); +test("channel context menu edits a stream", async ({ page }) => { + await page.goto("/"); + + await page.getByTestId("channel-general").click({ button: "right" }); + const editItem = page.getByRole("menuitem", { name: "Edit channel" }); + await expect(editItem).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Archive channel" }), + ).toBeVisible(); + + await editItem.click(); + const editDialog = page.getByRole("dialog", { name: "Edit channel" }); + await expect(editDialog).toBeVisible(); + await expect(editDialog.getByTestId("channel-management-name")).toHaveValue( + "general", + ); + + await editDialog.getByTestId("channel-management-name").fill("general-chat"); + await editDialog.getByTestId("channel-management-save-changes").click(); + await expect(editDialog).not.toBeVisible(); + await expect(page.getByTestId("stream-list")).toContainText("general-chat"); + + await page.getByRole("button", { name: "Close" }).click(); + await page.getByTestId("channel-management-trigger").click(); + await expect(editDialog).toHaveCount(0); +}); + +test("channel context menu hides management actions from members and DMs", async ({ + page, +}) => { + await page.goto("/"); + + await page.getByTestId("channel-random").click({ button: "right" }); + await expect( + page.getByRole("menuitem", { name: "Edit channel" }), + ).toHaveCount(0); + await expect( + page.getByRole("menuitem", { name: "Archive channel" }), + ).toHaveCount(0); + await page.keyboard.press("Escape"); + + const firstDm = page + .getByTestId("dm-list") + .locator('[data-testid^="channel-"]') + .first(); + await firstDm.click({ button: "right" }); + await expect( + page.getByRole("menuitem", { name: "Edit channel" }), + ).toHaveCount(0); + await expect( + page.getByRole("menuitem", { name: "Archive channel" }), + ).toHaveCount(0); +}); + +test("channel context menu archives a stream", async ({ page }) => { + await page.goto("/"); + + await page.getByTestId("channel-general").click({ button: "right" }); + const archiveItem = page.getByRole("menuitem", { name: "Archive channel" }); + await expect(archiveItem).toBeVisible(); + await archiveItem.click(); + + await expect(page.getByTestId("stream-list")).not.toContainText("general"); + await openChannelBrowser(page); + await expect(page.getByTestId("browse-channel-general")).toContainText( + "archived", + ); +}); + test("manage channel can archive and unarchive a stream", async ({ page }) => { await page.goto("/"); await openChannelManagement(page, "general"); From 7fac474ea95a3e47a1f2af2a09dcd7e421fbe7cd Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Mon, 13 Jul 2026 18:12:30 -0700 Subject: [PATCH 2/6] Address review feedback round 2 Keep context-menu capabilities stable per open session while warming permission queries, make edit requests replayable, and strengthen permission assertions. Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- desktop/src/app/AppShell.tsx | 14 +- desktop/src/app/AppShellOverlays.tsx | 6 +- .../channels/ui/ChannelManagementSheet.tsx | 40 ++-- .../sidebar/ui/ChannelContextMenu.tsx | 57 +++++- .../sidebar/ui/CustomChannelSection.tsx | 172 +++++++++--------- .../features/sidebar/ui/SidebarSection.tsx | 36 ++-- desktop/tests/e2e/channels.spec.ts | 20 +- 7 files changed, 200 insertions(+), 145 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 1b12c7eeb4..55011f5e11 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -106,8 +106,8 @@ export function AppShell() { const [managedChannelId, setManagedChannelId] = React.useState( null, ); - const [openChannelManagementInEditMode, setOpenChannelManagementInEditMode] = - React.useState(false); + const [channelManagementRequest, setChannelManagementRequest] = + React.useState({ edit: false, id: 0 }); const [searchFocusRequest, setSearchFocusRequest] = React.useState(0); const [browseDialogType, setBrowseDialogType] = React.useState(null); @@ -646,7 +646,10 @@ export function AppShell() { setManagedChannelId( typeof channelId === "string" ? channelId : null, ); - setOpenChannelManagementInEditMode(options?.edit === true); + setChannelManagementRequest((request) => ({ + edit: options?.edit === true, + id: request.id + 1, + })); setIsChannelManagementOpen(true); }, getChannelReadAt, @@ -912,16 +915,13 @@ export function AppShell() { channels={channels} currentPubkey={identityQuery.data?.pubkey} isChannelManagementOpen={isChannelManagementOpen} - openChannelManagementInEditMode={ - openChannelManagementInEditMode - } + channelManagementRequest={channelManagementRequest} onBrowseChannelJoin={handleBrowseChannelJoin} onBrowseDialogOpenChange={handleBrowseDialogOpenChange} onChannelManagementOpenChange={(open) => { setIsChannelManagementOpen(open); if (!open) { setManagedChannelId(null); - setOpenChannelManagementInEditMode(false); } }} onDeleteActiveChannel={() => { diff --git a/desktop/src/app/AppShellOverlays.tsx b/desktop/src/app/AppShellOverlays.tsx index ebb9bb3da4..bf408a9fab 100644 --- a/desktop/src/app/AppShellOverlays.tsx +++ b/desktop/src/app/AppShellOverlays.tsx @@ -21,7 +21,7 @@ type AppShellOverlaysProps = { channels: Channel[]; currentPubkey?: string; isChannelManagementOpen: boolean; - openChannelManagementInEditMode: boolean; + channelManagementRequest: { edit: boolean; id: number }; onBrowseChannelJoin: (channelId: string) => Promise; onBrowseDialogOpenChange: (open: boolean) => void; onChannelManagementOpenChange: (open: boolean) => void; @@ -35,7 +35,7 @@ export function AppShellOverlays({ channels, currentPubkey, isChannelManagementOpen, - openChannelManagementInEditMode, + channelManagementRequest, onBrowseChannelJoin, onBrowseDialogOpenChange, onChannelManagementOpenChange, @@ -82,7 +82,7 @@ export function AppShellOverlays({ void; onOpenChange: (open: boolean) => void; @@ -101,7 +101,7 @@ export function ChannelManagementSheet({ animateSplitEnter = false, channel, currentPubkey, - initiallyEditing = false, + managementRequest = { edit: false, id: 0 }, layout = "overlay", onDeleted, onOpenChange, @@ -203,10 +203,12 @@ export function ChannelManagementSheet({ // Sync drafts from server only when the sheet opens or the channel changes - // not on every background refetch, which would clobber in-flight edits. const syncedForRef = React.useRef(null); + const appliedEditRequestRef = React.useRef(0); React.useEffect(() => { if (!open) { // Reset on close so the next open re-syncs from server. syncedForRef.current = null; + appliedEditRequestRef.current = managementRequest.id; setIsDeleteDialogOpen(false); setIsEditDialogOpen(false); setActiveView("summary"); @@ -217,23 +219,25 @@ export function ChannelManagementSheet({ } const key = detail.id; - if (syncedForRef.current === key) { - return; + if (syncedForRef.current !== key) { + syncedForRef.current = key; + setNameDraft(detail.name); + setDescriptionDraft(detail.description); + setTopicDraft(detail.topic ?? ""); + setPurposeDraft(detail.purpose ?? ""); + setIsPrivateDraft(detail.visibility === "private"); + setIsEphemeralDraft(detail.ttlSeconds !== null); + setTtlDraft( + detail.ttlSeconds !== null ? formatTtlDuration(detail.ttlSeconds) : "", + ); + setActiveView("summary"); } - syncedForRef.current = key; - - setNameDraft(detail.name); - setDescriptionDraft(detail.description); - setTopicDraft(detail.topic ?? ""); - setPurposeDraft(detail.purpose ?? ""); - setIsPrivateDraft(detail.visibility === "private"); - setIsEphemeralDraft(detail.ttlSeconds !== null); - setTtlDraft( - detail.ttlSeconds !== null ? formatTtlDuration(detail.ttlSeconds) : "", - ); - setIsEditDialogOpen(initiallyEditing); - setActiveView("summary"); - }, [detail, initiallyEditing, open]); + + if (managementRequest.id !== appliedEditRequestRef.current) { + appliedEditRequestRef.current = managementRequest.id; + setIsEditDialogOpen(managementRequest.edit); + } + }, [detail, managementRequest.edit, managementRequest.id, open]); if (!channel) { return null; diff --git a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx index 1f6777f155..93b27d0d6f 100644 --- a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx +++ b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx @@ -1,3 +1,5 @@ +import * as React from "react"; + import { Archive, Bell, @@ -32,13 +34,40 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import type { Channel } from "@/shared/api/types"; import { copyTextToClipboard } from "@/shared/lib/clipboard"; import { + ContextMenu, + ContextMenuContent, ContextMenuItem, ContextMenuSeparator, + ContextMenuTrigger, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, } from "@/shared/ui/context-menu"; +export function ChannelContextMenu({ + children, + contentProps, + modal, +}: { + children: React.ReactNode; + contentProps: Omit< + React.ComponentProps, + "menuOpen" + >; + modal?: boolean; +}) { + const [open, setOpen] = React.useState(false); + + return ( + + {children} + + + + + ); +} + function MoveToSectionSubmenu({ channelId, sections, @@ -143,6 +172,7 @@ function CopyChannelSubmenu({ channel }: { channel: Channel }) { export function ChannelContextMenuItems({ channel, hasUnread, + menuOpen = false, isMuted, isStarred, sections, @@ -160,6 +190,7 @@ export function ChannelContextMenuItems({ }: { channel: Channel; hasUnread: boolean; + menuOpen?: boolean; isMuted?: boolean; isStarred?: boolean; sections?: ChannelSection[]; @@ -203,12 +234,26 @@ export function ChannelContextMenuItems({ currentPubkey, ), ); - const showManagementActions = - channel.channelType !== "dm" && - channel.archivedAt === null && - (selfMember?.role === "owner" || - selfMember?.role === "admin" || - canManageOwnedAgentChannel); + const canManageChannel = + selfMember?.role === "owner" || + selfMember?.role === "admin" || + canManageOwnedAgentChannel; + // Snapshot capabilities for each open session. Permission responses that + // arrive after the menu opens apply on the next open instead of inserting + // immediate actions beneath the user's pointer. + const [showManagementActions, setShowManagementActions] = + React.useState(false); + const menuWasOpenRef = React.useRef(false); + React.useLayoutEffect(() => { + if (menuOpen && !menuWasOpenRef.current) { + setShowManagementActions( + channel.channelType !== "dm" && + channel.archivedAt === null && + canManageChannel, + ); + } + menuWasOpenRef.current = menuOpen; + }, [canManageChannel, channel.archivedAt, channel.channelType, menuOpen]); const showStar = Boolean(onStarChannel && onUnstarChannel); const showReadToggle = hasUnread ? Boolean(onMarkChannelRead) diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index 5818c430ad..3cc6cf933a 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -41,7 +41,7 @@ import { SidebarMenuItem, } from "@/shared/ui/sidebar"; import { ChannelMenuButton } from "@/features/sidebar/ui/SidebarSection"; -import { ChannelContextMenuItems } from "@/features/sidebar/ui/ChannelContextMenu"; +import { ChannelContextMenu } from "@/features/sidebar/ui/ChannelContextMenu"; import { deferMenuAction } from "@/features/sidebar/ui/sidebarMenuHelpers"; import { DraggableChannelRow, @@ -415,24 +415,31 @@ export function ChannelGroupSection({ // AlertDialog. A modal ContextMenu would leave `pointer-events: none` // stuck on when it closes as the dialog mounts, freezing the // whole app. Non-modal avoids installing that body guard entirely. - - - - {draggable ? ( - - - - ) : ( + + + {draggable ? ( + - )} - - - - - - + + ) : ( + + )} + + ))} ) : null; @@ -705,52 +702,49 @@ export function CustomChannelSection({ // modal={false}: see note on the other channel ContextMenu // above — avoids the pointer-events lockup when Leave // channel's AlertDialog opens. - - - - - - - - - - - - + + + + + + + ))} ) : null} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 84b4a7a27b..2bf4e603ac 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -9,13 +9,7 @@ import { X, } from "lucide-react"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuTrigger, -} from "@/shared/ui/context-menu"; - -import { ChannelContextMenuItems } from "@/features/sidebar/ui/ChannelContextMenu"; +import { ChannelContextMenu } from "@/features/sidebar/ui/ChannelContextMenu"; import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel"; @@ -488,20 +482,20 @@ export function SidebarSection({ // The shared menu always renders copy actions, so every row // gets a context menu regardless of read/mute availability. return ( - - {menuItem} - - - - + + {menuItem} + ); })} diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 1cb28503e2..968956eae3 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -11,6 +11,7 @@ import { const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; +const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d"; const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8); // Relay-only agent owned by the mock viewer (see e2eBridge.ts // OWNED_RELAY_AGENT_PUBKEY). Classified as a bot via mockRelayAgents and @@ -2365,6 +2366,23 @@ test("channel context menu hides management actions from members and DMs", async }) => { await page.goto("/"); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await page.getByTestId("channel-management-trigger").click(); + await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); + await expect + .poll(async () => { + const commandLog = await readCommandPayloadLog(page); + return commandLog.some( + ({ command, payload }) => + command === "get_channel_members" && + (payload as { channelId?: string }).channelId === RANDOM_CHANNEL_ID, + ); + }) + .toBe(true); + await expect(page.getByTestId("channel-management-edit")).toHaveCount(0); + await page.getByTestId("auxiliary-panel-close").click(); + await page.getByTestId("channel-random").click({ button: "right" }); await expect( page.getByRole("menuitem", { name: "Edit channel" }), @@ -2395,7 +2413,7 @@ test("channel context menu archives a stream", async ({ page }) => { await expect(archiveItem).toBeVisible(); await archiveItem.click(); - await expect(page.getByTestId("stream-list")).not.toContainText("general"); + await expect(page.getByTestId("channel-general")).toHaveCount(0); await openChannelBrowser(page); await expect(page.getByTestId("browse-channel-general")).toContainText( "archived", From 1eaf0e04a907734b30fd19e7beb9da23808784f3 Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Mon, 13 Jul 2026 18:21:58 -0700 Subject: [PATCH 3/6] Address review feedback round 3 Reserve disabled management rows until permissions resolve so cold-cache menus remain safe and stable. Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- .../sidebar/ui/ChannelContextMenu.tsx | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx index 93b27d0d6f..565c8690f8 100644 --- a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx +++ b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx @@ -61,7 +61,7 @@ export function ChannelContextMenu({ return ( {children} - + @@ -238,22 +238,18 @@ export function ChannelContextMenuItems({ selfMember?.role === "owner" || selfMember?.role === "admin" || canManageOwnedAgentChannel; - // Snapshot capabilities for each open session. Permission responses that - // arrive after the menu opens apply on the next open instead of inserting - // immediate actions beneath the user's pointer. - const [showManagementActions, setShowManagementActions] = - React.useState(false); - const menuWasOpenRef = React.useRef(false); - React.useLayoutEffect(() => { - if (menuOpen && !menuWasOpenRef.current) { - setShowManagementActions( - channel.channelType !== "dm" && - channel.archivedAt === null && - canManageChannel, - ); - } - menuWasOpenRef.current = menuOpen; - }, [canManageChannel, channel.archivedAt, channel.channelType, menuOpen]); + const managementActionsAreResolved = + menuOpen && + membersQuery.data !== undefined && + (ownerPubkeys.length === 0 || ownerProfilesQuery.data !== undefined); + const managementActionsAreEligible = + channel.channelType !== "dm" && channel.archivedAt === null; + // Reserve the final two action rows while permissions load. If the viewer can + // manage this channel, the rows become actionable without changing the menu + // geometry; otherwise they disappear without ever exposing Archive. + const showManagementActions = + managementActionsAreEligible && + (!managementActionsAreResolved || canManageChannel); const showStar = Boolean(onStarChannel && onUnstarChannel); const showReadToggle = hasUnread ? Boolean(onMarkChannelRead) @@ -284,6 +280,7 @@ export function ChannelContextMenuItems({ <> deferMenuAction(() => openChannelManagement(channel.id, { edit: true }), @@ -297,6 +294,7 @@ export function ChannelContextMenuItems({ deferMenuAction(() => { void archiveChannelMutation.mutateAsync().catch((error) => { From 120bb7d2a22b78d30208da1245dc5f595d440154 Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Mon, 13 Jul 2026 18:28:07 -0700 Subject: [PATCH 4/6] Address review feedback round 4 Freeze management action visibility for each mounted menu session to prevent stale refetches from shifting actionable rows. Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- .../sidebar/ui/ChannelContextMenu.tsx | 36 +++++++------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx index 565c8690f8..12eecccd93 100644 --- a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx +++ b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx @@ -50,19 +50,14 @@ export function ChannelContextMenu({ modal, }: { children: React.ReactNode; - contentProps: Omit< - React.ComponentProps, - "menuOpen" - >; + contentProps: React.ComponentProps; modal?: boolean; }) { - const [open, setOpen] = React.useState(false); - return ( - + {children} - + ); @@ -172,7 +167,6 @@ function CopyChannelSubmenu({ channel }: { channel: Channel }) { export function ChannelContextMenuItems({ channel, hasUnread, - menuOpen = false, isMuted, isStarred, sections, @@ -190,7 +184,6 @@ export function ChannelContextMenuItems({ }: { channel: Channel; hasUnread: boolean; - menuOpen?: boolean; isMuted?: boolean; isStarred?: boolean; sections?: ChannelSection[]; @@ -238,18 +231,15 @@ export function ChannelContextMenuItems({ selfMember?.role === "owner" || selfMember?.role === "admin" || canManageOwnedAgentChannel; - const managementActionsAreResolved = - menuOpen && - membersQuery.data !== undefined && - (ownerPubkeys.length === 0 || ownerProfilesQuery.data !== undefined); - const managementActionsAreEligible = - channel.channelType !== "dm" && channel.archivedAt === null; - // Reserve the final two action rows while permissions load. If the viewer can - // manage this channel, the rows become actionable without changing the menu - // geometry; otherwise they disappear without ever exposing Archive. - const showManagementActions = - managementActionsAreEligible && - (!managementActionsAreResolved || canManageChannel); + // Context-menu content mounts once per open session, so this capability is a + // stable snapshot. Late query/refetch results cannot insert an immediate + // Archive action or move another item beneath the user's pointer. + const [showManagementActions] = React.useState( + () => + channel.channelType !== "dm" && + channel.archivedAt === null && + canManageChannel, + ); const showStar = Boolean(onStarChannel && onUnstarChannel); const showReadToggle = hasUnread ? Boolean(onMarkChannelRead) @@ -280,7 +270,6 @@ export function ChannelContextMenuItems({ <> deferMenuAction(() => openChannelManagement(channel.id, { edit: true }), @@ -294,7 +283,6 @@ export function ChannelContextMenuItems({ deferMenuAction(() => { void archiveChannelMutation.mutateAsync().catch((error) => { From e1dcc064c7df7e06140e89c149742c735886077b Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Mon, 13 Jul 2026 18:45:03 -0700 Subject: [PATCH 5/6] Address review feedback round 5 Keep cold-cache channel management actions visible but disabled until permissions resolve, and cover the loading state plus negative menu assertions. Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- .../sidebar/ui/ChannelContextMenu.tsx | 25 +++++++++++-------- desktop/src/testing/e2eBridge.ts | 6 +++++ desktop/tests/e2e/channels.spec.ts | 11 ++++++++ desktop/tests/helpers/bridge.ts | 1 + 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx index 12eecccd93..8467f09c97 100644 --- a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx +++ b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx @@ -1,5 +1,3 @@ -import * as React from "react"; - import { Archive, Bell, @@ -231,15 +229,18 @@ export function ChannelContextMenuItems({ selfMember?.role === "owner" || selfMember?.role === "admin" || canManageOwnedAgentChannel; - // Context-menu content mounts once per open session, so this capability is a - // stable snapshot. Late query/refetch results cannot insert an immediate - // Archive action or move another item beneath the user's pointer. - const [showManagementActions] = React.useState( - () => - channel.channelType !== "dm" && - channel.archivedAt === null && - canManageChannel, - ); + const hasResolvedMembers = membersQuery.data !== undefined; + const hasResolvedOwnerProfiles = + ownerPubkeys.length === 0 || ownerProfilesQuery.data !== undefined; + const isManagementCandidate = + channel.channelType !== "dm" && channel.archivedAt === null; + // Reserve disabled rows while capability queries resolve. This keeps the + // menu stable under the pointer without making owners close and reopen a + // cold-cache menu before the actions appear. + const showManagementActions = + isManagementCandidate && + (!hasResolvedMembers || !hasResolvedOwnerProfiles || canManageChannel); + const disableManagementActions = !canManageChannel; const showStar = Boolean(onStarChannel && onUnstarChannel); const showReadToggle = hasUnread ? Boolean(onMarkChannelRead) @@ -270,6 +271,7 @@ export function ChannelContextMenuItems({ <> deferMenuAction(() => openChannelManagement(channel.id, { edit: true }), @@ -283,6 +285,7 @@ export function ChannelContextMenuItems({ deferMenuAction(() => { void archiveChannelMutation.mutateAsync().catch((error) => { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 41054deab2..c36db54563 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -143,6 +143,7 @@ type E2eConfig = { * tests can observe the in-flight prepend window. 0/undefined = instant. */ channelWindowDelayMs?: number; profileReadDelayMs?: number; + channelMembersDelayMs?: number; profileReadError?: string; profileUpdateError?: string; profileUpdateErrors?: string[]; @@ -5503,6 +5504,11 @@ async function handleGetChannelMembers( args: { channelId: string }, config: E2eConfig | undefined, ): Promise { + const delayMs = config?.mock?.channelMembersDelayMs ?? 0; + if (delayMs > 0) { + await new Promise((resolve) => window.setTimeout(resolve, delayMs)); + } + const identity = getIdentity(config); if (!identity) { const channel = getMockChannel(args.channelId); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 968956eae3..a07378ce1c 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2335,8 +2335,17 @@ test("open channel management supports join and leave", async ({ page }) => { }); test("channel context menu edits a stream", async ({ page }) => { + await installMockBridge(page, { channelMembersDelayMs: 500 }); await page.goto("/"); + await page.getByTestId("channel-agents").click({ button: "right" }); + const pendingEditItem = page.getByRole("menuitem", { name: "Edit channel" }); + await expect(pendingEditItem).toBeVisible(); + await expect(pendingEditItem).toBeDisabled(); + await expect(pendingEditItem).toBeEnabled(); + await page.keyboard.press("Escape"); + await expect(pendingEditItem).toHaveCount(0); + await page.getByTestId("channel-general").click({ button: "right" }); const editItem = page.getByRole("menuitem", { name: "Edit channel" }); await expect(editItem).toBeVisible(); @@ -2384,6 +2393,7 @@ test("channel context menu hides management actions from members and DMs", async await page.getByTestId("auxiliary-panel-close").click(); await page.getByTestId("channel-random").click({ button: "right" }); + await expect(page.getByRole("menuitem", { name: "Copy" })).toBeVisible(); await expect( page.getByRole("menuitem", { name: "Edit channel" }), ).toHaveCount(0); @@ -2397,6 +2407,7 @@ test("channel context menu hides management actions from members and DMs", async .locator('[data-testid^="channel-"]') .first(); await firstDm.click({ button: "right" }); + await expect(page.getByRole("menuitem", { name: "Copy" })).toBeVisible(); await expect( page.getByRole("menuitem", { name: "Edit channel" }), ).toHaveCount(0); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 27e3fef136..486c6dbb7d 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -168,6 +168,7 @@ type MockBridgeOptions = { /** Delay (ms) for older-history fetches; see e2eBridge mock config. */ channelWindowDelayMs?: number; profileReadDelayMs?: number; + channelMembersDelayMs?: number; profileReadError?: string; profileUpdateError?: string; profileUpdateErrors?: string[]; From bc49be33e2987058c1417b39cc45999a4c4abd94 Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Mon, 13 Jul 2026 18:49:08 -0700 Subject: [PATCH 6/6] Address review feedback round 6 Keep permission-loading rows stable for the open menu, treat query failures as resolved, and make the cold-cache assertion deterministic. Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- .../sidebar/ui/ChannelContextMenu.tsx | 23 ++++++++++++------- desktop/tests/e2e/channels.spec.ts | 2 +- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx index 8467f09c97..00664ebdb2 100644 --- a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx +++ b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx @@ -1,3 +1,5 @@ +import * as React from "react"; + import { Archive, Bell, @@ -229,17 +231,22 @@ export function ChannelContextMenuItems({ selfMember?.role === "owner" || selfMember?.role === "admin" || canManageOwnedAgentChannel; - const hasResolvedMembers = membersQuery.data !== undefined; + const hasResolvedMembers = + membersQuery.data !== undefined || membersQuery.isError; const hasResolvedOwnerProfiles = - ownerPubkeys.length === 0 || ownerProfilesQuery.data !== undefined; + ownerPubkeys.length === 0 || + ownerProfilesQuery.data !== undefined || + ownerProfilesQuery.isError; const isManagementCandidate = channel.channelType !== "dm" && channel.archivedAt === null; - // Reserve disabled rows while capability queries resolve. This keeps the - // menu stable under the pointer without making owners close and reopen a - // cold-cache menu before the actions appear. - const showManagementActions = - isManagementCandidate && - (!hasResolvedMembers || !hasResolvedOwnerProfiles || canManageChannel); + // Context-menu content mounts per open session. Once these rows are reserved, + // keep them until close so permission resolution cannot move another action + // beneath the pointer; eligible rows transition from disabled to enabled. + const [showManagementActions] = React.useState( + () => + isManagementCandidate && + (!hasResolvedMembers || !hasResolvedOwnerProfiles || canManageChannel), + ); const disableManagementActions = !canManageChannel; const showStar = Boolean(onStarChannel && onUnstarChannel); const showReadToggle = hasUnread diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index a07378ce1c..f8516ab1e4 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2335,7 +2335,7 @@ test("open channel management supports join and leave", async ({ page }) => { }); test("channel context menu edits a stream", async ({ page }) => { - await installMockBridge(page, { channelMembersDelayMs: 500 }); + await installMockBridge(page, { channelMembersDelayMs: 2_000 }); await page.goto("/"); await page.getByTestId("channel-agents").click({ button: "right" });