Preserve persistent agent audience order (#1989)

Signed-off-by: npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
morgmart
2026-07-16 16:18:07 -07:00
committed by GitHub
co-authored by npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex
parent 9b6497402f
commit fb8e6827c7
17 changed files with 531 additions and 128 deletions
@@ -757,7 +757,6 @@ export const ChannelPane = React.memo(function ChannelPane({
<WelcomeComposerBanner state={welcomeComposerBannerState} />
) : null}
<MessageComposer
audienceContext={{ type: "timeline" }}
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
channelType={activeChannel?.channelType ?? null}
@@ -769,6 +769,7 @@ export function HomeView({
item={selectedItem}
latchedDefaultParentId={latchedDefaultParentId}
messages={contextMessages}
profiles={feedProfiles}
selectedEventId={selectedEventId}
onBack={
isSinglePanelDetailView
@@ -18,11 +18,14 @@ import {
hasSameMessageAuthor,
isWithinGroupingWindow,
} from "@/features/messages/lib/messageGrouping";
import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys";
import { getThreadReference } from "@/features/messages/lib/threading";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll";
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
import type { Channel } from "@/shared/api/types";
import type { Channel, UserProfileSummary } from "@/shared/api/types";
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
@@ -56,6 +59,7 @@ type InboxDetailPaneProps = {
isThreadContextLoading?: boolean;
item: InboxItem | null;
messages?: InboxContextMessage[];
profiles?: Record<string, UserProfileSummary>;
replies?: InboxReply[];
channel: Channel | null;
contextChannelName?: string | null;
@@ -108,6 +112,7 @@ export function InboxDetailPane({
isThreadContextLoading = false,
item,
messages = [],
profiles,
replies = [],
channel,
contextChannelName = null,
@@ -138,6 +143,40 @@ export function InboxDetailPane({
// Live arrivals rerun its layout compensation without changing the target.
const selectedMessage = messages.find((message) => message.isSelected);
// A latest reply can represent an Inbox conversation. Resolve the actual
// root from loaded context or the complete feed group; never treat an
// unresolved root/profile lookup as an authoritative empty audience.
const contextRoot = messages.find((message) => message.id === conversationId);
const feedRoot = item
? [item.item, ...item.groupItems].find(
(groupItem) => groupItem.id === conversationId,
)
: undefined;
const rootMessage = contextRoot
? {
authorPubkey: contextRoot.authorPubkey,
content: contextRoot.content,
mentionPubkeysByName: contextRoot.mentionPubkeysByName,
}
: feedRoot && profiles
? {
authorPubkey: feedRoot.pubkey,
content: feedRoot.content,
mentionPubkeysByName: resolveMentionProps(feedRoot.tags, profiles)
.mentionPubkeysByName,
}
: null;
const initialAgentPubkeys = rootMessage
? currentPubkey &&
normalizePubkey(rootMessage.authorPubkey) ===
normalizePubkey(currentPubkey)
? orderMentionPubkeysByText(
rootMessage.content,
rootMessage.mentionPubkeysByName,
(pubkey) => agentPubkeys?.has(pubkey) === true,
)
: []
: undefined;
const pendingReplyMessages: InboxDisplayMessage[] = replies.map((reply) => ({
...reply,
depth: reply.depth ?? (selectedMessage?.depth ?? 0) + 1,
@@ -475,6 +514,7 @@ export function InboxDetailPane({
audienceContext={{
type: "thread",
threadRootId: item.conversationId,
initialAgentPubkeys,
}}
channelId={item.item.channelId}
channelName={item.channelLabel ?? "channel"}
@@ -17,11 +17,16 @@ function escapeRegExp(str: string): string {
*
* Exported separately so it can be unit-tested without importing React.
*/
export function hasMention(text: string, name: string): boolean {
export function getMentionOffset(text: string, name: string): number | null {
const escaped = escapeRegExp(name);
const pattern = new RegExp(
`(?:^|\\s|\\(|[*_]{1,3}|\\|\\|)@${escaped}(?=\\|\\||[\\s,;.!?:)\\]}*_]|$)`,
`(^|\\s|\\(|[*_]{1,3}|\\|\\|)(@${escaped})(?=\\|\\||[\\s,;.!?:)\\]}*_]|$)`,
"i",
);
return pattern.test(text);
const match = pattern.exec(text);
return match ? match.index + match[1].length : null;
}
export function hasMention(text: string, name: string): boolean {
return getMentionOffset(text, name) !== null;
}
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
import { orderMentionPubkeysByText } from "./orderMentionPubkeys.ts";
const AGENT_A = "a".repeat(64);
const AGENT_B = "b".repeat(64);
test("orders eligible mention pubkeys by authored text instead of map insertion", () => {
const ordered = orderMentionPubkeysByText(
"@Vogue please pair with @Morgarita",
{ morgarita: AGENT_A, vogue: AGENT_B },
() => true,
);
assert.deepEqual(ordered, [AGENT_B, AGENT_A]);
});
test("dedupes aliases at their earliest authored position", () => {
const ordered = orderMentionPubkeysByText(
"@Morg please pair with @Vogue and @Morgarita",
{ morgarita: AGENT_A, vogue: AGENT_B, morg: AGENT_A },
() => true,
);
assert.deepEqual(ordered, [AGENT_A, AGENT_B]);
});
@@ -0,0 +1,30 @@
import { getMentionOffset } from "@/features/messages/lib/hasMention";
import { normalizePubkey } from "@/shared/lib/pubkey";
export function orderMentionPubkeysByText(
text: string,
mentionPubkeysByName: Readonly<Record<string, string>> | undefined,
isEligible: (pubkey: string) => boolean,
): string[] {
if (!mentionPubkeysByName) return [];
const earliestOffsetByPubkey = new Map<string, number>();
for (const [name, pubkey] of Object.entries(mentionPubkeysByName)) {
const normalized = normalizePubkey(pubkey);
const offset = getMentionOffset(text, name);
if (offset === null || !isEligible(normalized)) continue;
const previousOffset = earliestOffsetByPubkey.get(normalized);
if (previousOffset === undefined || offset < previousOffset) {
earliestOffsetByPubkey.set(normalized, offset);
}
}
return [...earliestOffsetByPubkey.entries()]
.sort(([leftPubkey, leftOffset], [rightPubkey, rightOffset]) =>
leftOffset === rightOffset
? leftPubkey.localeCompare(rightPubkey)
: leftOffset - rightOffset,
)
.map(([pubkey]) => pubkey);
}
@@ -32,35 +32,35 @@ function savedAudiences() {
test("conversation scopes isolate identities, channels, and threads", async () => {
const store = await loadStore();
const channelA = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "channel-a",
});
const channelB = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "channel-b",
});
const threadA1 = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "channel-a",
threadRootId: "root-1",
});
const threadA2 = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "channel-a",
threadRootId: "root-2",
});
const otherIdentity = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerB,
channelId: "channel-a",
});
const scopes = [
store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "channel-a",
threadRootId: "root-1",
}),
store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "channel-a",
threadRootId: "root-2",
}),
store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "channel-b",
threadRootId: "root-1",
}),
store.getPersistentAgentAudienceScope({
ownerPubkey: ownerB,
channelId: "channel-a",
threadRootId: "root-1",
}),
];
for (const scope of [channelA, channelB, threadA1, threadA2, otherIdentity]) {
for (const scope of scopes) {
assert.ok(scope);
store.setPersistentAgentAudience(scope, [agentA]);
}
assert.equal(new Set(Object.keys(savedAudiences())).size, 5);
assert.equal(new Set(Object.keys(savedAudiences())).size, 4);
});
test("successful fast send promotes without a persisted draft key", async () => {
@@ -68,6 +68,7 @@ test("successful fast send promotes without a persisted draft key", async () =>
const scope = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "channel-a",
threadRootId: "root",
});
store.setPersistentAgentAudienceEnabled(true);
@@ -206,42 +207,26 @@ test("new recipients retain explicit mention order", async () => {
assert.deepEqual(savedAudiences(), { [scope]: [agentB, agentA] });
});
test("first new-message send resolves its destination after capturing generation", async () => {
test("timeline scope is intentionally unsupported", async () => {
const store = await loadStore(7);
const capturedGeneration = store.getPersistentAgentAudienceGeneration();
store.setPersistentAgentAudienceEnabled(true);
const scope = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "resolved-dm",
});
store.promotePersistentAgentAudience({
expectedGeneration: capturedGeneration,
expectedRevision: null,
scope,
explicitAgentPubkeys: [agentA],
});
assert.deepEqual(savedAudiences(), { [scope]: [agentA] });
assert.equal(
store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "channel-a",
}),
null,
);
});
test("disable during new-message destination preparation invalidates promotion", async () => {
const store = await loadStore(8);
test("thread root audience initializes once and explicit clear wins on reopen", async () => {
const store = await loadStore(10);
const scope = `${ownerA}:channel-a:thread:root`;
store.setPersistentAgentAudienceEnabled(true);
const capturedGeneration = store.getPersistentAgentAudienceGeneration();
store.setPersistentAgentAudienceEnabled(false);
store.setPersistentAgentAudienceEnabled(true);
const scope = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "resolved-dm",
});
store.promotePersistentAgentAudience({
expectedGeneration: capturedGeneration,
expectedRevision: null,
scope,
explicitAgentPubkeys: [agentA],
});
store.initializePersistentAgentAudience(scope, [agentB, agentA]);
assert.deepEqual(savedAudiences(), { [scope]: [agentB, agentA] });
assert.deepEqual(savedAudiences(), {});
store.setPersistentAgentAudience(scope, []);
store.initializePersistentAgentAudience(scope, [agentA]);
assert.deepEqual(savedAudiences(), { [scope]: [] });
});
@@ -113,8 +113,8 @@ export function getPersistentAgentAudienceScope({
}: PersistentAgentAudienceScopeInput): string | null {
const owner = ownerPubkey.trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(owner) || !channelId) return null;
const conversation = threadRootId ? `thread:${threadRootId}` : "timeline";
return `${owner}:${channelId}:${conversation}`;
if (!threadRootId) return null;
return `${owner}:${channelId}:thread:${threadRootId}`;
}
export function getPersistentAgentAudienceGeneration(): number {
@@ -125,6 +125,14 @@ export function getPersistentAgentAudienceRevision(scope: string): number {
return revisions.get(scope) ?? defaultRevision;
}
export function initializePersistentAgentAudience(
scope: string,
pubkeys: Iterable<string>,
): void {
if (!enabled || !scope || Object.hasOwn(audiences, scope)) return;
setPersistentAgentAudience(scope, pubkeys);
}
export function setPersistentAgentAudience(
scope: string,
pubkeys: Iterable<string>,
@@ -208,6 +216,7 @@ export function usePersistentAgentAudience(scope: string | null): {
promotePubkeys: typeof promotePersistentAgentAudience;
removePubkey: (pubkey: string) => void;
clear: () => void;
initialize: (pubkeys: Iterable<string>) => void;
} {
const state = React.useSyncExternalStore(
subscribe,
@@ -232,5 +241,9 @@ export function usePersistentAgentAudience(scope: string | null): {
() => setPersistentAgentAudience(resolvedScope, []),
[resolvedScope],
),
initialize: React.useCallback(
(pubkeys) => initializePersistentAgentAudience(resolvedScope, pubkeys),
[resolvedScope],
),
};
}
@@ -768,20 +768,20 @@ export function useMentions(
managedAgentPubkeys.has(normalizePubkey(pubkey)),
[managedAgentPubkeys],
);
const autocompleteGenerationRef = React.useRef(0);
const updateMentionQuery = React.useCallback(
(value: string, cursorPosition: number) => {
// Stash the latest values so the debounced callback always uses fresh data.
const generation = ++autocompleteGenerationRef.current;
latestValueRef.current = value;
latestCursorRef.current = cursorPosition;
// Clear any previously scheduled detection.
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
if (generation !== autocompleteGenerationRef.current) return;
const mention = detectPrefixQuery(
"@",
@@ -866,18 +866,24 @@ export function useMentions(
[activePersonaById],
);
const clearMentions = React.useCallback(() => {
const cancelMentionAutocomplete = React.useCallback(() => {
autocompleteGenerationRef.current += 1;
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
flushedMentionStartIndexRef.current = null;
setMentionQuery(null);
setMentionSelectedIndex(0);
}, []);
const clearMentions = React.useCallback(() => {
cancelMentionAutocomplete();
mentionMapRef.current.clear();
personaMentionMapRef.current.clear();
setSelectedMentionNames([]);
setSelectedAgentMentionNames([]);
setMentionQuery(null);
setMentionSelectedIndex(0);
}, []);
}, [cancelMentionAutocomplete]);
const handleMentionKeyDown = React.useCallback(
(
@@ -965,6 +971,7 @@ export function useMentions(
);
return {
cancelMentionAutocomplete,
clearMentions,
extractMentionPersonas,
extractMentionPubkeys,
@@ -613,6 +613,21 @@ export function useRichTextEditor({
[editor],
);
const setContentAndFocusEnd = React.useCallback(
(markdown: string) => {
if (!editor) return;
// The caller already synchronizes composer state. Keep this programmatic
// restoration out of user-edit observers (autocomplete/reconciliation),
// then move selection in the same command chain.
editor
.chain()
.setContent(markdown, { emitUpdate: false })
.focus("end")
.run();
},
[editor],
);
const focusEnd = React.useCallback(() => {
editor?.commands.focus("end");
}, [editor]);
@@ -806,6 +821,7 @@ export function useRichTextEditor({
isEmpty,
clearContent,
setContent,
setContentAndFocusEnd,
focus,
focusEnd,
focusPreserve,
@@ -60,10 +60,11 @@ import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionH
import { useComposerContentState } from "./useComposerContentState";
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
type MessageComposerAudienceContext =
| { type: "timeline" }
| { type: "thread"; threadRootId: string };
type MessageComposerAudienceContext = {
type: "thread";
threadRootId: string;
initialAgentPubkeys?: readonly string[];
};
type MessageComposerProps = {
audienceContext?: MessageComposerAudienceContext | null;
channelId?: string | null;
@@ -198,10 +199,9 @@ function MessageComposerImpl({
const identityQuery = useIdentityQuery();
const effectiveDraftKey = draftKey ?? channelId;
const ownerPubkey = identityQuery.data?.pubkey ?? null;
const audienceThreadRootId =
audienceContext?.type === "thread" ? audienceContext.threadRootId : null;
const audienceThreadRootId = audienceContext?.threadRootId ?? null;
const audienceScope =
audienceContext && channelId && ownerPubkey
audienceThreadRootId && channelId && ownerPubkey
? getPersistentAgentAudienceScope({
ownerPubkey,
channelId,
@@ -364,6 +364,7 @@ function MessageComposerImpl({
const persistentMentionHydration = usePersistentAgentMentionHydration({
audienceScope,
hydrationKey: effectiveDraftKey,
initialAgentPubkeys: audienceContext?.initialAgentPubkeys,
isEditing: editTarget != null,
mentions,
richText,
@@ -401,6 +402,7 @@ function MessageComposerImpl({
persistentAudience.promotePubkeys({ ...promotion, scope });
}
: undefined,
resolvePostSendContent: persistentMentionHydration.resolvePostSendContent,
});
// biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger
@@ -1,6 +1,10 @@
import * as React from "react";
import { ArrowDown } from "lucide-react";
import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import {
buildThreadSummaryFromVisibleEntries,
hasNestedThreadBranches,
@@ -596,6 +600,30 @@ export function MessageThreadPanel({
targetMessageId: scrollTargetId,
});
const knownAgentPubkeys = useKnownAgentPubkeys();
const initialAgentPubkeys = React.useMemo(() => {
if (
!threadHead ||
!currentPubkey ||
normalizePubkey(threadHead.signerPubkey ?? threadHead.pubkey ?? "") !==
normalizePubkey(currentPubkey)
) {
return [];
}
const { mentionPubkeysByName } = resolveMentionProps(
threadHead.tags,
profiles,
);
if (!mentionPubkeysByName) return [];
return orderMentionPubkeysByText(
threadHead.body,
mentionPubkeysByName,
(pubkey) =>
knownAgentPubkeys.has(pubkey) || profiles?.[pubkey]?.isAgent === true,
);
}, [currentPubkey, knownAgentPubkeys, profiles, threadHead]);
if (!threadHead) {
return null;
}
@@ -884,6 +912,7 @@ export function MessageThreadPanel({
audienceContext={{
type: "thread",
threadRootId: threadHead.id,
initialAgentPubkeys,
}}
channelId={channelId}
channelName={channelName}
@@ -594,7 +594,6 @@ export function NewMessageScreen() {
) : null}
<MessageComposer
audienceContext={{ type: "timeline" }}
channelName="new message"
channelType="dm"
containerClassName="px-5"
@@ -16,8 +16,8 @@ test("supported conversation hosts opt into explicit audience contexts", async (
],
);
assert.match(channelPane, /audienceContext=\{\{ type: "timeline" \}\}/);
assert.match(newMessage, /audienceContext=\{\{ type: "timeline" \}\}/);
assert.doesNotMatch(channelPane, /audienceContext=/);
assert.doesNotMatch(newMessage, /audienceContext=/);
assert.match(
threadPanel,
/type: "thread"[\s\S]*threadRootId: threadHead\.id/,
@@ -43,5 +43,5 @@ test("composer never derives audience context from draft keys", async () => {
const composer = await source("./MessageComposer.tsx");
assert.doesNotMatch(composer, /draftKey\?\.startsWith\("thread:"\)/);
assert.match(composer, /audienceContext\?\.type === "thread"/);
assert.match(composer, /audienceContext\?\.threadRootId/);
});
@@ -91,7 +91,10 @@ type UseMentionSendFlowOptions = {
} | null,
) => Promise<void>
>;
richText: Pick<UseRichTextEditorResult, "clearContent" | "setContent">;
richText: Pick<
UseRichTextEditorResult,
"clearContent" | "setContent" | "setContentAndFocusEnd"
>;
setContent: (content: string) => void;
setIsEmojiPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
setPendingImeta: (pendingImeta: ImetaMedia[]) => void;
@@ -104,6 +107,7 @@ type UseMentionSendFlowOptions = {
expectedRevision: number | null;
explicitAgentPubkeys: string[];
}) => void;
resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string;
};
function mergeOutgoingTagsWithReferenceMentions(
@@ -159,6 +163,7 @@ export function useMentionSendFlow({
setPendingImeta,
setSpoileredAttachmentUrls,
onSuccessfulExplicitAgentAudience,
resolvePostSendContent,
}: UseMentionSendFlowOptions) {
const [pendingNonMemberSend, setPendingNonMemberSend] =
React.useState<PendingNonMemberMentionSend | null>(null);
@@ -382,29 +387,37 @@ export function useMentionSendFlow({
],
);
const clearComposer = React.useCallback(() => {
setPendingNonMemberSend(null);
setNonMemberPromptError(null);
setContent("");
contentRef.current = "";
richText.clearContent();
setPendingImeta([]);
setSpoileredAttachmentUrls?.(new Set());
mentions.clearMentions();
channelLinks.clearChannels();
emojiAutocomplete.clearEmojis();
setIsEmojiPickerOpen(false);
}, [
channelLinks.clearChannels,
contentRef,
emojiAutocomplete.clearEmojis,
mentions.clearMentions,
richText.clearContent,
setContent,
setIsEmojiPickerOpen,
setPendingImeta,
setSpoileredAttachmentUrls,
]);
const clearComposer = React.useCallback(
(postSendContent = "") => {
setPendingNonMemberSend(null);
setNonMemberPromptError(null);
setContent(postSendContent);
contentRef.current = postSendContent;
if (postSendContent) {
richText.setContentAndFocusEnd(postSendContent);
mentions.cancelMentionAutocomplete();
} else richText.clearContent();
setPendingImeta([]);
setSpoileredAttachmentUrls?.(new Set());
if (!postSendContent) mentions.clearMentions();
channelLinks.clearChannels();
emojiAutocomplete.clearEmojis();
setIsEmojiPickerOpen(false);
},
[
channelLinks.clearChannels,
contentRef,
emojiAutocomplete.clearEmojis,
mentions.cancelMentionAutocomplete,
mentions.clearMentions,
richText.clearContent,
richText.setContentAndFocusEnd,
setContent,
setIsEmojiPickerOpen,
setPendingImeta,
setSpoileredAttachmentUrls,
],
);
React.useEffect(() => {
if (previousChannelIdRef.current === channelId) {
@@ -486,11 +499,19 @@ export function useMentionSendFlow({
return;
}
// Only clear the composer if the user has not switched channels since
// submit. If they have, the composer they see belongs to the new channel
// and we must not wipe it.
const effectiveExplicitAgentPubkeys =
filterEffectiveExplicitAgentPubkeys(
draft.explicitAgentPubkeys,
mentionPubkeys,
);
// Replace the sent body directly with its final post-send state before
// the async network send starts. This avoids an intermediate blank frame
// for persistent audiences while preserving the ordinary empty state.
if (draft.capturedChannelId === channelIdRef.current) {
clearComposer();
clearComposer(
resolvePostSendContent?.(effectiveExplicitAgentPubkeys),
);
}
try {
@@ -501,11 +522,6 @@ export function useMentionSendFlow({
sendChannelId,
draft.capturedThreadContext,
);
const effectiveExplicitAgentPubkeys =
filterEffectiveExplicitAgentPubkeys(
draft.explicitAgentPubkeys,
mentionPubkeys,
);
if (effectiveExplicitAgentPubkeys.length > 0) {
// Promote only explicitly authored agents that remained effective
// for this successful send. "Send without inviting" removes its
@@ -556,6 +572,7 @@ export function useMentionSendFlow({
onPrepareSendChannel,
onSendRef,
onSuccessfulExplicitAgentAudience,
resolvePostSendContent,
richText.setContent,
setContent,
setPendingImeta,
@@ -7,12 +7,14 @@ import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTex
export function usePersistentAgentMentionHydration({
audienceScope,
hydrationKey,
initialAgentPubkeys,
isEditing,
mentions,
richText,
}: {
audienceScope: string | null;
hydrationKey: string | null | undefined;
initialAgentPubkeys?: readonly string[];
isEditing: boolean;
mentions: UseMentionsResult;
richText: UseRichTextEditorResult;
@@ -24,8 +26,13 @@ export function usePersistentAgentMentionHydration({
scopeRef.current = audienceScope;
const isEditingRef = React.useRef(isEditing);
isEditingRef.current = isEditing;
React.useEffect(() => {
if (!audienceScope || !initialAgentPubkeys) return;
audience.initialize(initialAgentPubkeys);
}, [audience.initialize, audienceScope, initialAgentPubkeys]);
const isRestoringRef = React.useRef(false);
const isSubmittingRef = React.useRef(false);
const cancelHydrationAutocompleteRef = React.useRef(false);
const hydratedRef = React.useRef(false);
const hydrate = React.useCallback(() => {
@@ -69,6 +76,7 @@ export function usePersistentAgentMentionHydration({
replaceFromOffset: prefixLength,
replaceToOffset: prefixLength,
});
cancelHydrationAutocompleteRef.current = true;
richText.replacePlainTextRange(
edit.replaceFromOffset,
edit.replaceToOffset,
@@ -78,6 +86,12 @@ export function usePersistentAgentMentionHydration({
}
hydratedRef.current = scopeRef.current === capturedScope;
isRestoringRef.current = false;
if (cancelHydrationAutocompleteRef.current) {
cancelHydrationAutocompleteRef.current = false;
// Hydration is a programmatic transition, not an authored query. Cancel
// only when its editor updates actually scheduled autocomplete work.
mentions.cancelMentionAutocomplete();
}
}, [audience.enabled, audience.pubkeys, audienceScope, mentions, richText]);
const reconcile = React.useCallback(
@@ -97,9 +111,15 @@ export function usePersistentAgentMentionHydration({
[mentions.extractMentionPubkeys],
);
const hydrateRef = React.useRef(hydrate);
hydrateRef.current = hydrate;
const scheduleHydration = React.useCallback(
() => requestAnimationFrame(hydrate),
[hydrate],
(cancelAutocomplete = false) =>
requestAnimationFrame(() => {
hydrateRef.current();
if (cancelAutocomplete) mentions.cancelMentionAutocomplete();
}),
[mentions.cancelMentionAutocomplete],
);
React.useEffect(() => {
void hydrationKey;
@@ -108,6 +128,37 @@ export function usePersistentAgentMentionHydration({
return () => cancelAnimationFrame(frame);
}, [hydrationKey, scheduleHydration]);
const resolvePostSendContent = React.useCallback(
(explicitAgentPubkeys: string[]) => {
if (!audience.enabled || !audienceScope || isEditingRef.current)
return "";
const orderedPubkeys = [
...new Set([...explicitAgentPubkeys, ...audience.pubkeys]),
];
const targets = orderedPubkeys
.map((pubkey) => ({
pubkey,
displayName: mentions.getMentionDisplayName(pubkey),
}))
.filter((target): target is { pubkey: string; displayName: string } =>
Boolean(target.displayName),
);
mentions.clearMentions();
for (const target of targets) {
mentions.registerMentionPubkey(target.displayName, target.pubkey, {
isAgent: true,
});
}
isRestoringRef.current = true;
hydratedRef.current = true;
return (
targets.map((target) => `@${target.displayName}`).join(" ") +
(targets.length > 0 ? " " : "")
);
},
[audience.enabled, audience.pubkeys, audienceScope, mentions],
);
return {
audience,
beginSubmit: () => {
@@ -115,9 +166,10 @@ export function usePersistentAgentMentionHydration({
},
endSubmit: () => {
isSubmittingRef.current = false;
scheduleHydration();
scheduleHydration(true);
},
reconcile,
resolvePostSendContent,
scheduleHydration,
};
}
@@ -8,7 +8,8 @@ const OWNER = "deadbeef".repeat(8);
const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const AGENT_A = "a".repeat(64);
const AGENT_B = "b".repeat(64);
const SCOPE = `${OWNER}:${CHANNEL_ID}:timeline`;
const THREAD_ROOT_ID = "mock-general-welcome";
const SCOPE = `${OWNER}:${CHANNEL_ID}:thread:${THREAD_ROOT_ID}`;
async function seedAudience(page: Page, pubkeys: string[], theme = "buzz") {
await page.addInitScript(
@@ -31,8 +32,54 @@ async function openGeneral(page: Page) {
await expect(page.getByTestId("chat-title")).toHaveText("general");
}
async function installAudienceFixtures(page: Page) {
async function openThread(page: Page, threadRootId = THREAD_ROOT_ID) {
await page.goto(
`/#/channels/${CHANNEL_ID}?messageId=${threadRootId}&thread=${threadRootId}`,
{ waitUntil: "domcontentloaded" },
);
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
}
async function emitRootMessage(
page: Page,
content: string,
mentionPubkeys: string[],
) {
const event = await page.evaluate(
({ message, pubkeys }) =>
(
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
mentionPubkeys: string[];
}) => { id: string };
}
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: message,
mentionPubkeys: pubkeys,
}),
{ message: content, pubkeys: mentionPubkeys },
);
if (!event) throw new Error("Mock message emitter is not installed");
return event;
}
function channelComposer(page: Page) {
return page.getByTestId("channel-composer-overlay");
}
function threadComposer(page: Page) {
return page.getByTestId("thread-composer-overlay");
}
async function installAudienceFixtures(
page: Page,
options: { sendMessageDelayMs?: number } = {},
) {
await installMockBridge(page, {
...options,
managedAgents: [
{
pubkey: AGENT_A,
@@ -50,14 +97,144 @@ async function installAudienceFixtures(page: Page) {
});
}
test("first thread open inherits explicitly addressed agents in authored order", async ({
page,
}) => {
await page.addInitScript(() => {
window.localStorage.setItem("buzz:keep-addressed-agents-active", "1");
});
await installAudienceFixtures(page);
await openGeneral(page);
const root = await emitRootMessage(
page,
"@Vogue please pair with @Morgarita",
// Event tag order deliberately opposes authored mention order.
[AGENT_A, AGENT_B],
);
await openThread(page, root.id);
const input = threadComposer(page).getByTestId("message-input");
await expect(input).toHaveText("@Vogue @Morgarita ");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(2);
await expect
.poll(() =>
page.evaluate(
({ owner, channelId, rootId }) => {
const stored = JSON.parse(
localStorage.getItem("buzz:persistent-agent-audiences:v2") ?? "{}",
);
return stored[`${owner}:${channelId}:thread:${rootId}`] ?? null;
},
{ owner: OWNER, channelId: CHANNEL_ID, rootId: root.id },
),
)
.toEqual([AGENT_B, AGENT_A]);
});
test("persistent agents transition atomically before Enter-send resolves", async ({
page,
}) => {
await seedAudience(page, [AGENT_A]);
await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 });
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
const send = composer.getByTestId("send-message");
await input.fill("@Morgarita hello");
await input.press("Enter");
// The network send is still pending, so this is the first observable
// post-submit editor state rather than the later success hydration pass.
await expect(input).toHaveText("@Morgarita ", { timeout: 500 });
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1, {
timeout: 500,
});
await expect(input).toBeFocused();
await page.waitForTimeout(200);
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
await expect(send).toBeEnabled();
await expect
.poll(() =>
input.evaluate((element) => {
const selection = window.getSelection();
const viewDesc = (
element as HTMLElement & {
pmViewDesc?: {
posFromDOM: (node: Node, offset: number, bias: number) => number;
size: number;
};
}
).pmViewDesc;
if (!selection?.anchorNode || !viewDesc) return null;
const position = viewDesc.posFromDOM(
selection.anchorNode,
selection.anchorOffset,
1,
);
// The root view desc includes the document's two boundary tokens,
// while posFromDOM is relative to the editable root. Converting both
// to ProseMirror coordinates proves selection.from/to === doc.content.size.
return {
empty: selection.isCollapsed,
atDocumentEnd: position + 1 === viewDesc.size - 2,
};
}),
)
.toEqual({ empty: true, atDocumentEnd: true });
});
test("timeline agent send remains one-shot and returns to the placeholder", async ({
page,
}) => {
await seedAudience(page, [AGENT_A]);
await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 });
await openGeneral(page);
const composer = channelComposer(page);
const input = composer.getByTestId("message-input");
await input.fill("@Mor");
await composer
.getByTestId("mention-autocomplete")
.getByText("Morgarita", { exact: true })
.click();
await input.pressSequentially("hello");
await expect(input).toHaveText("@Morgarita hello");
await input.press("Enter");
await expect(input).toHaveText("", { timeout: 500 });
await expect(input.locator("[data-placeholder]").first()).toHaveAttribute(
"data-placeholder",
"Message #general",
{ timeout: 500 },
);
await expect(input).toBeFocused();
await expect
.poll(() =>
input.evaluate((element) => {
const selection = window.getSelection();
return {
collapsed: selection?.isCollapsed ?? false,
inside: Boolean(
selection?.anchorNode && element.contains(selection.anchorNode),
),
};
}),
)
.toEqual({ collapsed: true, inside: true });
});
test("persistent agents restore through the native inline mention UI", async ({
page,
}) => {
await seedAudience(page, [AGENT_B, AGENT_A]);
await installAudienceFixtures(page);
await openGeneral(page);
await openThread(page);
const input = page.getByTestId("message-input");
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
await expect(input).toHaveText("@Vogue @Morgarita ");
await expect(page.getByText("Talking to", { exact: true })).toHaveCount(0);
await expect(input.locator(".agent-mention-highlight")).toHaveCount(2);
@@ -77,7 +254,7 @@ test("persistent agents restore through the native inline mention UI", async ({
)
.toEqual([AGENT_A]);
await page.getByTestId("send-message").click();
await composer.getByTestId("send-message").click();
await expect(input).toContainText("@Morgarita");
await expect(input).not.toContainText("@Vogue");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
@@ -87,9 +264,10 @@ for (const theme of ["buzz", "buzz-dark"]) {
test(`captures native persistent mentions in ${theme}`, async ({ page }) => {
await seedAudience(page, [AGENT_A, AGENT_B], theme);
await installAudienceFixtures(page);
await openGeneral(page);
const composer = page.getByTestId("message-composer");
await page.getByTestId("message-input").focus();
await openThread(page);
const overlay = threadComposer(page);
const composer = overlay.getByTestId("message-composer");
await overlay.getByTestId("message-input").focus();
await waitForAnimations(page);
await composer.screenshot({
path: `${SHOTS}/${theme}-native-mentions.png`,
@@ -101,9 +279,12 @@ test("native persistent mentions fit the narrow composer", async ({ page }) => {
await page.setViewportSize({ width: 700, height: 760 });
await seedAudience(page, [AGENT_A, AGENT_B]);
await installAudienceFixtures(page);
await openGeneral(page);
const composer = page.getByTestId("message-composer");
await expect(page.getByTestId("message-input")).toContainText("@Morgarita");
await openThread(page);
const overlay = threadComposer(page);
const composer = overlay.getByTestId("message-composer");
await expect(overlay.getByTestId("message-input")).toContainText(
"@Morgarita",
);
await waitForAnimations(page);
await composer.screenshot({ path: `${SHOTS}/narrow-native-mentions.png` });
});