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[];