Disable wave huddles while bot members resolve

This commit is contained in:
klopez4212
2026-06-29 13:36:44 +01:00
parent d31f3c3408
commit 89052d4175
11 changed files with 188 additions and 35 deletions
@@ -0,0 +1,54 @@
import type { Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export function getDmHuddleMemberPubkeys(
channel: Channel | null,
agentPubkeys: ReadonlySet<string> | undefined,
currentPubkey: string | undefined,
) {
if (channel?.channelType !== "dm" || !agentPubkeys) {
return [];
}
const normalizedCurrentPubkey = currentPubkey
? normalizePubkey(currentPubkey)
: null;
const seen = new Set<string>();
return channel.participantPubkeys.filter((pubkey) => {
const normalizedPubkey = normalizePubkey(pubkey);
if (
normalizedCurrentPubkey &&
normalizedPubkey === normalizedCurrentPubkey
) {
return false;
}
if (!agentPubkeys.has(normalizedPubkey) || seen.has(normalizedPubkey)) {
return false;
}
seen.add(normalizedPubkey);
return true;
});
}
export function hasOtherDmParticipant(
channel: Channel | null,
currentPubkey: string | undefined,
) {
if (channel?.channelType !== "dm") {
return false;
}
const normalizedCurrentPubkey = currentPubkey
? normalizePubkey(currentPubkey)
: null;
return channel.participantPubkeys.some((pubkey) => {
const normalizedPubkey = normalizePubkey(pubkey);
return (
!normalizedCurrentPubkey || normalizedPubkey !== normalizedCurrentPubkey
);
});
}
@@ -13,6 +13,10 @@ import {
} from "@/features/messages/ui/MessageTimeline";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay";
import {
getDmHuddleMemberPubkeys,
hasOtherDmParticipant,
} from "@/features/channels/lib/dmHuddleMembers";
import {
buildVideoReviewCommentsByRootId,
buildVideoReviewContextForMessage,
@@ -65,11 +69,11 @@ import type { Channel } from "@/shared/api/types";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
type ChannelPaneProps = {
activeChannel: Channel | null;
activityAgents?: BotActivityAgent[];
agentPubkeys?: ReadonlySet<string>;
agentPubkeysPending?: boolean;
agentSessionAgents: ChannelAgentSessionAgent[];
botTypingEntries: TypingIndicatorEntry[];
channelFind: ReturnType<typeof useChannelFind>;
@@ -181,6 +185,7 @@ type ChannelPaneProps = {
export const ChannelPane = React.memo(function ChannelPane({
activeChannel,
agentPubkeys,
agentPubkeysPending = false,
agentSessionAgents,
activityAgents = agentSessionAgents,
botTypingEntries,
@@ -275,33 +280,14 @@ export const ChannelPane = React.memo(function ChannelPane({
!activeChannel.archivedAt;
const hasMainComposerOverlay = !isNonMemberView;
const activeChannelId = activeChannel?.id ?? null;
const huddleMemberPubkeys = React.useMemo(() => {
if (activeChannel?.channelType !== "dm" || !agentPubkeys) {
return [];
}
const normalizedCurrentPubkey = currentPubkey
? normalizePubkey(currentPubkey)
: null;
const seen = new Set<string>();
return activeChannel.participantPubkeys.filter((pubkey) => {
const normalizedPubkey = normalizePubkey(pubkey);
if (
normalizedCurrentPubkey &&
normalizedPubkey === normalizedCurrentPubkey
) {
return false;
}
if (!agentPubkeys.has(normalizedPubkey) || seen.has(normalizedPubkey)) {
return false;
}
seen.add(normalizedPubkey);
return true;
});
}, [activeChannel, agentPubkeys, currentPubkey]);
const huddleMemberPubkeys = React.useMemo(
() => getDmHuddleMemberPubkeys(activeChannel, agentPubkeys, currentPubkey),
[activeChannel, agentPubkeys, currentPubkey],
);
const huddleMemberPubkeysPending =
agentPubkeysPending &&
huddleMemberPubkeys.length === 0 &&
hasOtherDmParticipant(activeChannel, currentPubkey);
const isActiveWelcomeChannel =
activeChannel !== null && isWelcomeChannel(activeChannel);
useComposerHeightPadding(
@@ -706,6 +692,7 @@ export const ChannelPane = React.memo(function ChannelPane({
hasComposerOverlay={hasMainComposerOverlay}
hasOlderMessages={hasOlderMessages}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isFetchingOlder={isFetchingOlder}
isFollowingThreadById={isFollowingThreadById}
isMessageUnreadById={isMessageUnreadById}
@@ -872,6 +859,7 @@ export const ChannelPane = React.memo(function ChannelPane({
editTarget={threadEditTarget}
firstUnreadReplyId={threadFirstUnreadReplyId}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isFollowingThread={isFollowingThread}
isMessageUnreadById={isMessageUnreadById}
isSending={isSending}
@@ -320,6 +320,12 @@ export function ChannelScreen({
}
return pubkeys;
}, [channelMembers, managedAgents, messageProfilesQuery.data, relayAgents]);
const agentPubkeysPending =
activeChannel?.channelType === "dm" &&
(channelMembersQuery.isPending ||
managedAgentsQuery.isPending ||
relayAgentsQuery.isPending ||
(messageProfilePubkeys.length > 0 && messageProfilesQuery.isPending));
const {
agentSessionCandidates,
botTypingEntries,
@@ -765,6 +771,7 @@ export function ChannelScreen({
activeChannel={activeChannel}
activityAgents={channelAgentSessionAgents}
agentPubkeys={agentPubkeys}
agentPubkeysPending={agentPubkeysPending}
agentSessionAgents={agentSessionAgents}
botTypingEntries={botTypingEntries}
channelFind={channelFind}
@@ -57,6 +57,7 @@ export const MessageRow = React.memo(
highlightThreadLineDepths,
hoverBackground = true,
huddleMemberPubkeys,
huddleMemberPubkeysPending = false,
actionBarPlacement = "floating",
collapseDescendantsLabel,
isFollowingThread,
@@ -92,6 +93,7 @@ export const MessageRow = React.memo(
highlightThreadLineDepths?: ReadonlyArray<number>;
hoverBackground?: boolean;
huddleMemberPubkeys?: readonly string[];
huddleMemberPubkeysPending?: boolean;
actionBarPlacement?: "floating" | "inside";
collapseDescendantsLabel?: string;
isFollowingThread?: boolean;
@@ -293,6 +295,7 @@ export const MessageRow = React.memo(
channelId={channelId}
fallbackText={waveMessage.fallbackText}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
/>
);
}
@@ -779,6 +782,7 @@ export const MessageRow = React.memo(
prev.highlightThreadLineDepths === next.highlightThreadLineDepths &&
prev.hoverBackground === next.hoverBackground &&
prev.huddleMemberPubkeys === next.huddleMemberPubkeys &&
prev.huddleMemberPubkeysPending === next.huddleMemberPubkeysPending &&
prev.isFollowingThread === next.isFollowingThread &&
prev.isUnread === next.isUnread &&
prev.layoutVariant === next.layoutVariant &&
@@ -47,6 +47,7 @@ type MessageThreadPanelProps = {
disabled?: boolean;
firstUnreadReplyId?: string | null;
huddleMemberPubkeys?: readonly string[];
huddleMemberPubkeysPending?: boolean;
layout?: "standalone" | "split";
editTarget?: {
author: string;
@@ -348,6 +349,7 @@ export function MessageThreadPanel({
disabled = false,
firstUnreadReplyId,
huddleMemberPubkeys,
huddleMemberPubkeysPending = false,
layout = "standalone",
editTarget,
isSending,
@@ -633,6 +635,7 @@ export function MessageThreadPanel({
agentPubkeys={agentPubkeys}
channelId={channelId}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isFollowingThread={isFollowingThread}
isUnread={isMessageUnreadById?.(threadHead.id)}
layoutVariant="thread-reply"
@@ -752,6 +755,7 @@ export function MessageThreadPanel({
highlightThreadLineDepths={highlightedLineDepths}
hoverBackground={!entry.summary}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isUnread={isMessageUnreadById?.(entry.message.id)}
layoutVariant="thread-reply"
message={entry.message}
@@ -38,6 +38,7 @@ type MessageTimelineProps = {
channelName?: string;
channelType?: ChannelType | null;
huddleMemberPubkeys?: readonly string[];
huddleMemberPubkeysPending?: boolean;
messages: TimelineMessage[];
mainEntries?: MainTimelineEntry[];
directMessageIntro?: {
@@ -156,6 +157,7 @@ const MessageTimelineBase = React.forwardRef<
isFetchingOlder = false,
followThreadById,
huddleMemberPubkeys,
huddleMemberPubkeysPending = false,
isFollowingThreadById,
isMessageUnreadById,
messageFooters,
@@ -705,6 +707,7 @@ const MessageTimelineBase = React.forwardRef<
followThreadById={followThreadById}
highlightedMessageId={highlightedMessageId}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isFollowingThreadById={isFollowingThreadById}
isMessageUnreadById={isMessageUnreadById}
messageFooters={messageFooters}
@@ -36,6 +36,7 @@ type TimelineMessageListProps = {
channelType?: ChannelType | null;
currentPubkey?: string;
huddleMemberPubkeys?: readonly string[];
huddleMemberPubkeysPending?: boolean;
/** Event id of the oldest unread top-level message; renders a "New" divider above it. */
firstUnreadMessageId?: string | null;
followThreadById?: (rootId: string) => void;
@@ -96,6 +97,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
followThreadById,
highlightedMessageId = null,
huddleMemberPubkeys,
huddleMemberPubkeysPending = false,
isFollowingThreadById,
isMessageUnreadById,
messageFooters,
@@ -209,6 +211,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
footer={messageFooters?.[item.entry.message.id] ?? null}
highlightedMessageId={highlightedMessageId}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isFollowingThreadById={isFollowingThreadById}
isUnread={isMessageUnreadById?.(item.entry.message.id)}
onDelete={onDelete}
@@ -237,6 +240,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
followThreadById,
highlightedMessageId,
huddleMemberPubkeys,
huddleMemberPubkeysPending,
isFollowingThreadById,
isMessageUnreadById,
messageFooters,
@@ -323,6 +327,7 @@ type MessageRowItemProps = Pick<
| "followThreadById"
| "highlightedMessageId"
| "huddleMemberPubkeys"
| "huddleMemberPubkeysPending"
| "isFollowingThreadById"
| "onDelete"
| "onEdit"
@@ -352,6 +357,7 @@ function MessageRowItem({
footer,
highlightedMessageId,
huddleMemberPubkeys,
huddleMemberPubkeysPending,
isFollowingThreadById,
isUnread,
onDelete,
@@ -394,6 +400,7 @@ function MessageRowItem({
highlighted={false}
hoverBackground={false}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isFollowingThread={
isFollowingThreadById
? isFollowingThreadById(message.id)
@@ -442,6 +449,7 @@ function MessageRowItem({
channelId={channelId}
highlighted={message.id === highlightedMessageId || isSearchActive}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isUnread={isUnread}
message={message}
onDelete={canDelete}
@@ -18,22 +18,26 @@ type WaveMessageAttachmentProps = {
channelId?: string | null;
fallbackText: string;
huddleMemberPubkeys?: readonly string[];
huddleMemberPubkeysPending?: boolean;
};
export function WaveMessageAttachment({
channelId,
fallbackText,
huddleMemberPubkeys = [],
huddleMemberPubkeysPending = false,
}: WaveMessageAttachmentProps) {
const queryClient = useQueryClient();
const { isStarting, startHuddle } = useHuddle();
const startHuddleDisabled =
!channelId || isStarting || huddleMemberPubkeysPending;
const handleStartHuddle = React.useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (!channelId || isStarting) {
if (startHuddleDisabled) {
return;
}
@@ -46,7 +50,13 @@ export function WaveMessageAttachment({
);
}
},
[channelId, huddleMemberPubkeys, isStarting, queryClient, startHuddle],
[
channelId,
huddleMemberPubkeys,
queryClient,
startHuddle,
startHuddleDisabled,
],
);
return (
@@ -66,7 +76,7 @@ export function WaveMessageAttachment({
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
disabled={!channelId || isStarting}
disabled={startHuddleDisabled}
onClick={handleStartHuddle}
size="xs"
type="button"
+20 -4
View File
@@ -80,6 +80,7 @@ type E2eConfig = {
};
managedAgents?: MockManagedAgentSeed[];
relayAgents?: MockRelayAgentSeed[];
agentListDelayMs?: number;
agentMemory?: RawAgentMemoryListing | Record<string, RawAgentMemoryListing>;
createManagedAgentDelayMs?: number;
channelsReadError?: string;
@@ -4615,7 +4616,19 @@ async function handleGetFeed(
};
}
async function handleListRelayAgents(): Promise<RawRelayAgent[]> {
async function delayAgentList(config: E2eConfig | undefined) {
const agentListDelayMs = config?.mock?.agentListDelayMs ?? 0;
if (agentListDelayMs > 0) {
await new Promise<void>((resolve) => {
window.setTimeout(resolve, agentListDelayMs);
});
}
}
async function handleListRelayAgents(
config: E2eConfig | undefined,
): Promise<RawRelayAgent[]> {
await delayAgentList(config);
syncMockRelayAgentsFromManagedAgents();
return mockRelayAgents.map(cloneRelayAgent);
}
@@ -4742,7 +4755,10 @@ async function handleDiscoverManagedAgentPrereqs(
};
}
async function handleListManagedAgents(): Promise<RawManagedAgent[]> {
async function handleListManagedAgents(
config: E2eConfig | undefined,
): Promise<RawManagedAgent[]> {
await delayAgentList(config);
return mockManagedAgents.map(cloneManagedAgent);
}
@@ -6687,7 +6703,7 @@ export function maybeInstallE2eTauriMocks() {
activeConfig,
);
case "list_relay_agents":
return handleListRelayAgents();
return handleListRelayAgents(activeConfig);
case "list_personas":
return handleListPersonas();
case "create_persona":
@@ -6796,7 +6812,7 @@ export function maybeInstallE2eTauriMocks() {
case "export_persona_to_json":
return handleExportPersonaToJson(payload as { id: string });
case "list_managed_agents":
return handleListManagedAgents();
return handleListManagedAgents(activeConfig);
case "get_agent_memory":
return handleGetAgentMemory(
(payload as Parameters<typeof handleGetAgentMemory>[0]) ?? {},
+58
View File
@@ -20,6 +20,8 @@ const REUSABLE_PERSONA_AGENT_PUBKEY =
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
const ALLOWLIST_RELAY_AGENT_PUBKEY =
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
const DELAYED_RELAY_AGENT_PUBKEY =
"9999999999999999999999999999999999999999999999999999999999999999";
const CASEY_PROFILE_PUBKEY =
"1111111111111111111111111111111111111111111111111111111111111111";
const PROFILE_ONLY_AGENT_PUBKEY =
@@ -1358,3 +1360,59 @@ test("wave attachment huddle passes the bot DM pubkey", async ({ page }) => {
.poll(() => readStartHuddleMemberPubkeys(page))
.toEqual(expect.arrayContaining([TEST_IDENTITIES.charlie.pubkey]));
});
test("wave attachment huddle waits for delayed bot DM pubkey", async ({
page,
}) => {
await installMockBridge(page, {
agentListDelayMs: 5_000,
relayAgents: [
{
pubkey: DELAYED_RELAY_AGENT_PUBKEY,
name: "orbit",
channelNames: ["general"],
},
],
searchProfiles: [
{
pubkey: DELAYED_RELAY_AGENT_PUBKEY,
displayName: "orbit",
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await emitMockMessage(page, "general", "Orbit checking in.", {
pubkey: DELAYED_RELAY_AGENT_PUBKEY,
});
await waitForTimelineSettled(page);
const orbitMessage = page
.getByTestId("message-row")
.filter({ hasText: "Orbit checking in." })
.first();
await orbitMessage.locator("button").first().hover();
const profilePopover = page.locator(
'[data-testid="user-profile-popover"][data-state="open"]',
);
await expect(profilePopover).toBeVisible();
await profilePopover
.getByTestId(`user-profile-popover-wave-${DELAYED_RELAY_AGENT_PUBKEY}`)
.click();
const startHuddleButton = page
.getByTestId("message-wave-attachment")
.getByRole("button", { name: "Start huddle" });
await expect(startHuddleButton).toBeDisabled();
await expect(startHuddleButton).toBeEnabled({ timeout: 7_000 });
await startHuddleButton.click();
await expect
.poll(() => readStartHuddleMemberPubkeys(page))
.toEqual(expect.arrayContaining([DELAYED_RELAY_AGENT_PUBKEY]));
});
+1
View File
@@ -105,6 +105,7 @@ type MockBridgeOptions = {
};
managedAgents?: MockManagedAgentSeed[];
relayAgents?: MockRelayAgentSeed[];
agentListDelayMs?: number;
createManagedAgentDelayMs?: number;
channelsReadError?: string;
feedReadError?: string;