diff --git a/desktop/src/features/channels/lib/dmHuddleMembers.ts b/desktop/src/features/channels/lib/dmHuddleMembers.ts new file mode 100644 index 000000000..4da722120 --- /dev/null +++ b/desktop/src/features/channels/lib/dmHuddleMembers.ts @@ -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 | undefined, + currentPubkey: string | undefined, +) { + if (channel?.channelType !== "dm" || !agentPubkeys) { + return []; + } + + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + const seen = new Set(); + + 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 + ); + }); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index b0d804c35..8e32d5c7d 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -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; + agentPubkeysPending?: boolean; agentSessionAgents: ChannelAgentSessionAgent[]; botTypingEntries: TypingIndicatorEntry[]; channelFind: ReturnType; @@ -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(); - - 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} diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index c58236312..ff95563e4 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -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} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 4e5853ba6..f877e6419 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -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; 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 && diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index d1f046a09..6e2539388 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -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} diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 63ae084d7..38279eadd 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -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} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index dfbe680f5..011c8ab10 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -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} diff --git a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx index 090c59b34..0012d0b14 100644 --- a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx +++ b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx @@ -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) => { 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({ ; createManagedAgentDelayMs?: number; channelsReadError?: string; @@ -4615,7 +4616,19 @@ async function handleGetFeed( }; } -async function handleListRelayAgents(): Promise { +async function delayAgentList(config: E2eConfig | undefined) { + const agentListDelayMs = config?.mock?.agentListDelayMs ?? 0; + if (agentListDelayMs > 0) { + await new Promise((resolve) => { + window.setTimeout(resolve, agentListDelayMs); + }); + } +} + +async function handleListRelayAgents( + config: E2eConfig | undefined, +): Promise { + await delayAgentList(config); syncMockRelayAgentsFromManagedAgents(); return mockRelayAgents.map(cloneRelayAgent); } @@ -4742,7 +4755,10 @@ async function handleDiscoverManagedAgentPrereqs( }; } -async function handleListManagedAgents(): Promise { +async function handleListManagedAgents( + config: E2eConfig | undefined, +): Promise { + 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[0]) ?? {}, diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index f052cdf0f..672488ff6 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -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])); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index e1340e896..1e4da2e05 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -105,6 +105,7 @@ type MockBridgeOptions = { }; managedAgents?: MockManagedAgentSeed[]; relayAgents?: MockRelayAgentSeed[]; + agentListDelayMs?: number; createManagedAgentDelayMs?: number; channelsReadError?: string; feedReadError?: string;