mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex
parent
9b6497402f
commit
fb8e6827c7
@@ -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` });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user