From f2cda647e59f417332207833d70aca009161578b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 31 Aug 2026 13:46:03 -0400 Subject: [PATCH 1/5] feat(desktop): invite owned agents from standalone forums Reuse phase-aware preparation, authorized add and final destination authorization; retain selected drafts on cancellation and failure. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/playwright.config.ts | 1 + .../src/features/forum/ui/ForumComposer.tsx | 11 +- .../forum/ui/useForumMentionPreparation.ts | 173 +++++++++++ .../messages/ui/NonMemberMentionDialog.tsx | 21 +- .../tests/e2e/forum-agent-invitation.spec.ts | 273 ++++++++++++++++++ docs/forum-agent-invitation.md | 24 ++ 6 files changed, 495 insertions(+), 8 deletions(-) create mode 100644 desktop/src/features/forum/ui/useForumMentionPreparation.ts create mode 100644 desktop/tests/e2e/forum-agent-invitation.spec.ts create mode 100644 docs/forum-agent-invitation.md diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 1304e66ba3b..aad2580dad0 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -82,6 +82,7 @@ export default defineConfig({ "**/cloud-provenance.spec.ts", "**/mention-recipients.spec.ts", "**/remote-owned-mentions.spec.ts", + "**/forum-agent-invitation.spec.ts", "**/team-mentions.spec.ts", "**/persistent-agent-audience.spec.ts", "**/relay-reconnect.spec.ts", diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index fd661cc60ee..6a48b7779fe 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -20,6 +20,7 @@ import { useLinkEditor } from "@/features/messages/lib/useLinkEditor"; import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar"; +import { NonMemberMentionDialog } from "@/features/messages/ui/NonMemberMentionDialog"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; import { @@ -34,6 +35,7 @@ import { ForumComposerAutocompletes } from "./ForumComposerAutocompletes"; import { ForumComposerCompactLayout } from "./ForumComposerCompactLayout"; import { ForumComposerMediaStatus } from "./ForumComposerMediaStatus"; import { useCompactComposerInteractions } from "./useCompactComposerInteractions"; +import { useForumMentionPreparation } from "./useForumMentionPreparation"; export function ForumComposer({ channelId = null, @@ -73,6 +75,8 @@ export function ForumComposer({ }, [compact]); const mentions = useMentions(channelId, members, profiles, { channelType }); + const { prepareMentionPubkeys, nonMemberPromptProps } = + useForumMentionPreparation(channelId, channelType, mentions); const channelLinks = useChannelLinks(); const media = useMediaUpload(); const { handlePaperclipClick, handleToolbarMouseDown, shouldIgnoreBlur } = @@ -245,9 +249,11 @@ export function ForumComposer({ // A pasted mention's identity check can still be in flight; extracting // first would publish the label with no `p` tag. Bounded internally. await mentions.settlePendingMentionBindings(); - const pubkeys = await mentions.revalidateMentionPubkeys( + const pubkeys = await prepareMentionPubkeys( mentions.extractMentionPubkeys(trimmed), + trimmed, ); + if (pubkeys === null) return; // Reuse the shared send-path builder so forum/notes posts emit the same // body + imeta as chat: generic files become `[filename](url)` links with a @@ -296,8 +302,8 @@ export function ForumComposer({ media.setPendingImeta, mentions.cancelMentionAutocomplete, mentions.extractMentionPubkeys, - mentions.revalidateMentionPubkeys, mentions.settlePendingMentionBindings, + prepareMentionPubkeys, mentions.clearMentions, channelLinks.clearChannels, richText.clearContent, @@ -649,6 +655,7 @@ export function ForumComposer({ )} + {!isSubmissionPending && linkEditor.card} {!isSubmissionPending && linkEditor.dialog} diff --git a/desktop/src/features/forum/ui/useForumMentionPreparation.ts b/desktop/src/features/forum/ui/useForumMentionPreparation.ts new file mode 100644 index 00000000000..c3cc49510f9 --- /dev/null +++ b/desktop/src/features/forum/ui/useForumMentionPreparation.ts @@ -0,0 +1,173 @@ +import * as React from "react"; +import { useAddChannelMembersMutation } from "@/features/channels/hooks"; +import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/channelMemberAdmission"; +import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers"; +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { ChannelType } from "@/shared/api/types"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; + +type PendingInvite = { + channelId: string; + pubkeys: string[]; + nonMemberPubkeys: string[]; + intendedAgentPubkeys: string[]; + resolve: (invited: boolean) => void; +}; + +/** Adapt the normal mention Invite dialog and authorized add to standalone forums. */ +export function useForumMentionPreparation( + channelId: string | null, + channelType: ChannelType | null | undefined, + mentions: UseMentionsResult, +) { + const addMembers = useAddChannelMembersMutation(channelId); + const canInvite = useCanAddChannelMembers(channelId); + const [pending, setPending] = React.useState(null); + const [error, setError] = React.useState(null); + const [isInviting, setIsInviting] = React.useState(false); + const pendingRef = React.useRef(null); + const invitingRef = React.useRef(false); + const activeChannelRef = React.useRef(channelId); + activeChannelRef.current = channelId; + const mountedRef = React.useRef(false); + + const dismiss = React.useCallback(() => { + const draft = pendingRef.current; + pendingRef.current = null; + setPending(null); + setError(null); + draft?.resolve(false); + }, []); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + pendingRef.current?.resolve(false); + pendingRef.current = null; + }; + }, []); + React.useEffect(() => { + if (pendingRef.current?.channelId !== channelId) dismiss(); + }, [channelId, dismiss]); + + const prepareMentionPubkeys = React.useCallback( + async (pubkeys: string[], content: string) => { + const capturedChannelId = channelId; + const intendedAgentPubkeys = [ + ...pubkeys.filter(mentions.isAgentPubkey), + ...mentions + .getDraftMentionRefs(content) + .filter((ref) => ref.isAgent) + .map((ref) => ref.pubkey), + ]; + const agentPubkeys = new Set(intendedAgentPubkeys.map(normalizePubkey)); + // Local managed-agent lifecycle and channel-less/notes surfaces are not + // part of this adapter. Relay-only agents use the same bot add as chat. + const nonMemberPubkeys = + capturedChannelId && + channelType === "forum" && + mentions.hasResolvedMembers + ? [...new Set(pubkeys.map(normalizePubkey))].filter( + (pubkey) => + agentPubkeys.has(pubkey) && + !mentions.isManagedAgentPubkey(pubkey) && + !mentions.memberPubkeys.has(pubkey), + ) + : []; + if (capturedChannelId && nonMemberPubkeys.length > 0) { + const invited = await new Promise((resolve) => { + const draft = { + channelId: capturedChannelId, + pubkeys, + nonMemberPubkeys, + intendedAgentPubkeys, + resolve, + }; + pendingRef.current = draft; + setError(null); + setPending(draft); + }); + if (!invited) return null; + } + if (!mountedRef.current || activeChannelRef.current !== capturedChannelId) + return null; + // The add mutation awaits membership invalidation. Publication still + // requires a fresh authoritative directory/membership/policy read. + const validated = await mentions.revalidateMentionPubkeys( + pubkeys, + capturedChannelId, + { phase: "publish", intendedAgentPubkeys }, + ); + return mountedRef.current && + activeChannelRef.current === capturedChannelId + ? validated + : null; + }, + [channelId, channelType, mentions], + ); + + const invite = React.useCallback(async () => { + const draft = pendingRef.current; + if (!draft || invitingRef.current) return; + if (!canInvite) { + setError(PRIVATE_CHANNEL_ADD_DENIED_MESSAGE); + return; + } + const isCurrent = () => + mountedRef.current && + activeChannelRef.current === draft.channelId && + pendingRef.current === draft; + invitingRef.current = true; + setIsInviting(true); + setError(null); + try { + // Preparation admits eligible owned nonmembers, not arbitrary targets. + // Never use publication's membership gate before the authorized add. + await mentions.revalidateMentionPubkeys(draft.pubkeys, draft.channelId, { + phase: "prepare", + intendedAgentPubkeys: draft.intendedAgentPubkeys, + }); + if (!isCurrent()) return; + const result = await addMembers.mutateAsync({ + channelId: draft.channelId, + pubkeys: draft.nonMemberPubkeys, + role: "bot", + }); + if (!isCurrent()) return; + if (result.errors.length > 0) { + setError(result.errors.map((failure) => failure.error).join("; ")); + return; + } + pendingRef.current = null; + setPending(null); + draft.resolve(true); + } catch (failure) { + if (isCurrent()) + setError( + failure instanceof Error + ? failure.message + : "Could not invite members.", + ); + } finally { + invitingRef.current = false; + if (mountedRef.current) setIsInviting(false); + } + }, [addMembers.mutateAsync, canInvite, mentions.revalidateMentionPubkeys]); + + return { + prepareMentionPubkeys, + nonMemberPromptProps: { + canInvite, + error, + isInvitePending: isInviting, + names: (pending?.nonMemberPubkeys ?? []).map( + (pubkey) => + mentions.getMentionDisplayName(pubkey) ?? truncatePubkey(pubkey), + ), + onDismiss: dismiss, + onInvite: () => void invite(), + open: pending !== null, + }, + }; +} diff --git a/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx b/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx index c72686a6422..38f3825df44 100644 --- a/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx +++ b/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx @@ -16,7 +16,8 @@ type NonMemberMentionDialogProps = { isInvitePending: boolean; names: string[]; onDismiss: () => void; - onDoNothing: () => void; + /** Omit when publication requires the intended recipients to be invited. */ + onDoNothing?: () => void; onInvite: () => void; open: boolean; }; @@ -48,9 +49,13 @@ export function NonMemberMentionDialog({ {names.join(", ")} {names.length === 1 ? "is" : "are"} not in this channel.{" "} - {canInvite - ? "Invite them to the channel, or send without inviting them." - : `${PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} You can still send without inviting them.`} + {onDoNothing + ? canInvite + ? "Invite them to the channel, or send without inviting them." + : `${PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} You can still send without inviting them.` + : canInvite + ? "Invite them to the channel, or cancel to keep your draft." + : PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} {error ? ( @@ -61,12 +66,16 @@ export function NonMemberMentionDialog({ {canInvite ? (