diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 1b12c7eeb..55011f5e1 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 ebb9bb3da..bf408a9fa 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 1f6777f15..93b27d0d6 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 5818c430a..3cc6cf933 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 84b4a7a27..2bf4e603a 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 1cb28503e..968956eae 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",