mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
fix(desktop): make composer agent recipients predictable
Show resolved one-shot and automatic agent recipients consistently. Keep ordinary mention selection preference-respecting; reserve enabling automation for the explicit pin action. Remove all matching mentions on X and expose hidden recipients through a keyboard-accessible overflow list. Guard deferred restoration against removed recipients and stale conversation scope. Cover channel/thread, typed/toolbar selection, automation on/off, removal, re-add, upload settlement, scope changes, and keyboard overflow behavior. Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <5d97ac8c272fa949af56c71e586146b1706e5c4c3daa125db5b4625e12e0686c@buzz.block.builderlab.xyz> Co-authored-by: Mongo <81cabd2ca1792494372ed410283c00770c0cf35afca052e940fa522883212b45@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -41,7 +41,7 @@ const thirdAgent = {
|
||||
pubkey: "third-agent-pubkey",
|
||||
};
|
||||
|
||||
test("mention control expands with automatically mentioned agents", async () => {
|
||||
test("mention control expands with current message recipients", async () => {
|
||||
const React = await import("react");
|
||||
const { fireEvent, render } = await import("@testing-library/react");
|
||||
const { TooltipProvider } = await import("@/shared/ui/tooltip");
|
||||
@@ -72,7 +72,7 @@ test("mention control expands with automatically mentioned agents", async () =>
|
||||
const avatar = view.getByTestId("composer-address-lock-agent-pubkey");
|
||||
assert.ok(avatar);
|
||||
const manage = view.getByRole("button", {
|
||||
name: "Manage automatic agent mentions",
|
||||
name: "Mention someone",
|
||||
});
|
||||
assert.match(manage.className, /(?:^|\s)-ml-2(?:\s|$)/);
|
||||
assert.match(manage.className, /(?:^|\s)pl-2(?:\s|$)/);
|
||||
@@ -82,18 +82,18 @@ test("mention control expands with automatically mentioned agents", async () =>
|
||||
/(?:^|\s)pr-1\.5(?:\s|$)/,
|
||||
);
|
||||
assert.match(
|
||||
view.getByRole("button", { name: "Manage automatic agent mentions" })
|
||||
.parentElement?.className ?? "",
|
||||
view.getByRole("button", { name: "Mention someone" }).parentElement
|
||||
?.className ?? "",
|
||||
/(?:^|\s)bg-primary\/15(?:\s|$)/,
|
||||
);
|
||||
assert.match(
|
||||
view.getByRole("button", { name: "Manage automatic agent mentions" })
|
||||
.parentElement?.className ?? "",
|
||||
view.getByRole("button", { name: "Mention someone" }).parentElement
|
||||
?.className ?? "",
|
||||
/(?:^|\s)text-primary(?:\s|$)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
view.getByRole("button", { name: "Manage automatic agent mentions" })
|
||||
.parentElement?.className ?? "",
|
||||
view.getByRole("button", { name: "Mention someone" }).parentElement
|
||||
?.className ?? "",
|
||||
/(?:^|\s)bg-accent\/70(?:\s|$)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
|
||||
@@ -9,13 +9,19 @@ import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
export type ComposerAddressAgent = {
|
||||
avatarUrl: string | null;
|
||||
displayName: string;
|
||||
pubkey: string;
|
||||
isAutomatic?: boolean;
|
||||
};
|
||||
|
||||
type AddressAnimationProps = {
|
||||
@@ -77,16 +83,53 @@ function AddressedAgentAvatar({
|
||||
);
|
||||
}
|
||||
|
||||
function RemainingAgentCount({ count }: { count: number }) {
|
||||
return count > 0 ? (
|
||||
<span
|
||||
aria-label={`${count} more addressed ${count === 1 ? "agent" : "agents"}`}
|
||||
className="flex h-5 min-w-5 items-center justify-center rounded-full bg-muted px-1 text-3xs font-semibold text-muted-foreground ring-1 ring-border/70"
|
||||
role="img"
|
||||
>
|
||||
+{count}
|
||||
</span>
|
||||
) : null;
|
||||
function RemainingAgents({
|
||||
agents,
|
||||
disabled,
|
||||
onRemove,
|
||||
}: {
|
||||
agents: readonly ComposerAddressAgent[];
|
||||
disabled: boolean;
|
||||
onRemove: (pubkey: string) => void;
|
||||
}) {
|
||||
if (agents.length === 0) return null;
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-label={`Show ${agents.length} more mentioned agents`}
|
||||
className="flex h-5 min-w-5 items-center justify-center rounded-full bg-muted px-1 text-3xs font-semibold text-muted-foreground ring-1 ring-border/70 focus-visible:outline-hidden focus-visible:ring-ring"
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
>
|
||||
+{agents.length}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-1" side="top">
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
aria-label={`Remove ${agent.displayName} from this and future messages`}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={disabled}
|
||||
key={agent.pubkey}
|
||||
onClick={() => onRemove(agent.pubkey)}
|
||||
type="button"
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={agent.avatarUrl}
|
||||
displayName={agent.displayName}
|
||||
shape="squircle"
|
||||
size="xs"
|
||||
/>
|
||||
<span className="flex-1 truncate text-left">
|
||||
{agent.displayName}
|
||||
</span>
|
||||
<X aria-hidden="true" className="h-3 w-3" />
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const VISIBLE_AGENT_LIMIT = 3;
|
||||
@@ -150,7 +193,7 @@ export function ComposerMentionButton({
|
||||
showAgents: boolean;
|
||||
}) {
|
||||
const visibleAgents = showAgents ? agents.slice(0, VISIBLE_AGENT_LIMIT) : [];
|
||||
const hiddenCount = showAgents ? agents.length - visibleAgents.length : 0;
|
||||
const hiddenAgents = showAgents ? agents.slice(VISIBLE_AGENT_LIMIT) : [];
|
||||
const hasAgents = visibleAgents.length > 0;
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [showActiveChrome, setShowActiveChrome] = React.useState(hasAgents);
|
||||
@@ -180,11 +223,7 @@ export function ComposerMentionButton({
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={
|
||||
hasAgents
|
||||
? "Manage automatic agent mentions"
|
||||
: "Mention someone"
|
||||
}
|
||||
aria-label="Mention someone"
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center rounded-lg focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
showActiveChrome
|
||||
@@ -201,11 +240,7 @@ export function ComposerMentionButton({
|
||||
<AtSign aria-hidden="true" className="h-4 w-4 shrink-0" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{hasAgents
|
||||
? "Manage automatic agent mentions"
|
||||
: "Mention someone"}
|
||||
</TooltipContent>
|
||||
<TooltipContent>Mention someone</TooltipContent>
|
||||
</Tooltip>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
@@ -231,9 +266,9 @@ export function ComposerMentionButton({
|
||||
<Tooltip disableHoverableContent key={agent.pubkey}>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.button
|
||||
aria-label={`Don't automatically mention ${agent.displayName} in this conversation`}
|
||||
aria-label={`Remove ${agent.displayName} from this and future messages`}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="group/address relative rounded-full focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="group/address relative rounded-[30%] focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
|
||||
data-testid={`composer-address-lock-remove-${agent.pubkey}`}
|
||||
disabled={disabled}
|
||||
exit={
|
||||
@@ -266,19 +301,25 @@ export function ComposerMentionButton({
|
||||
shakeVersionByPubkey[agent.pubkey] ?? 0
|
||||
}
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full bg-foreground text-background opacity-0 transition-opacity group-hover/address:opacity-100 group-focus-visible/address:opacity-100">
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[30%] bg-foreground text-background opacity-0 transition-opacity group-hover/address:opacity-100 group-focus-visible/address:opacity-100">
|
||||
<X aria-hidden="true" className="h-3 w-3" />
|
||||
</span>
|
||||
</motion.button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Don't automatically mention {agent.displayName} in this
|
||||
conversation
|
||||
{agent.isAutomatic
|
||||
? `${agent.displayName} will be mentioned in this and future messages.`
|
||||
: `${agent.displayName} will be mentioned in this message only.`}
|
||||
{" Click to remove."}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<RemainingAgentCount count={hiddenCount} />
|
||||
<RemainingAgents
|
||||
agents={hiddenAgents}
|
||||
disabled={disabled}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</motion.span>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -56,6 +56,7 @@ import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill";
|
||||
import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
|
||||
import { useMentionSendFlow } from "./useMentionSendFlow";
|
||||
import { useAgentAddressLockPicker } from "./useAgentAddressLockPicker";
|
||||
import { useComposerAgentRecipients } from "./useComposerAgentRecipients";
|
||||
import { useAddressMentionPulse } from "./useAddressMentionPulse";
|
||||
import { useAlwaysAddressShortcut } from "./useAlwaysAddressShortcut";
|
||||
import { useComposerMentionPicker } from "./useComposerMentionPicker";
|
||||
@@ -129,7 +130,7 @@ function MessageComposerImpl({
|
||||
const effectiveDraftKey = draftKey ?? channelId;
|
||||
const ownerPubkey = identityQuery.data?.pubkey ?? null;
|
||||
const audienceScope =
|
||||
audienceContext && channelId && ownerPubkey
|
||||
audienceContext && channelId && ownerPubkey && editTarget == null
|
||||
? getPersistentAgentAudienceScope({
|
||||
ownerPubkey,
|
||||
channelId,
|
||||
@@ -333,7 +334,7 @@ function MessageComposerImpl({
|
||||
onTurnOn: () => setKeepMentionedAgentsPinned(true),
|
||||
});
|
||||
const addressedMentionRestore = useAddressedAgentMentionRestore({
|
||||
audiencePubkeys: persistentAudience.pubkeys,
|
||||
audienceScope,
|
||||
channelId,
|
||||
enabled: keepMentionedAgentsPinned,
|
||||
});
|
||||
@@ -464,6 +465,12 @@ function MessageComposerImpl({
|
||||
profiles,
|
||||
richText,
|
||||
});
|
||||
const addressedAgents = useComposerAgentRecipients({
|
||||
audiencePubkeys: persistentAudience.pubkeys,
|
||||
mentions,
|
||||
profiles,
|
||||
richText,
|
||||
});
|
||||
addressedMentionRestore.restoreAddressedAgentMentionsRef.current =
|
||||
restoreAddressedAgentMentions;
|
||||
React.useLayoutEffect(() => {
|
||||
@@ -936,7 +943,7 @@ function MessageComposerImpl({
|
||||
</div>
|
||||
<ComposerDockToolbar
|
||||
addressedAgents={
|
||||
editTarget == null && !composerDisabled ? lockedAgents : []
|
||||
audienceScope && !composerDisabled ? addressedAgents : []
|
||||
}
|
||||
autoPinConfirmationTitle={autoPinConfirmationTitle}
|
||||
layoutMode={layoutMode}
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, afterEach, before, beforeEach, test } from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const AGENT_A = "a".repeat(64);
|
||||
const AGENT_B = "b".repeat(64);
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
let nextFrame = 0;
|
||||
let frames = new Map();
|
||||
|
||||
function flushFrames() {
|
||||
const queuedFrames = frames;
|
||||
frames = new Map();
|
||||
for (const callback of queuedFrames.values()) callback(0);
|
||||
}
|
||||
|
||||
before(() => {
|
||||
Object.assign(globalThis, {
|
||||
cancelAnimationFrame: (frame) => frames.delete(frame),
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
requestAnimationFrame: (callback) => {
|
||||
nextFrame += 1;
|
||||
frames.set(nextFrame, callback);
|
||||
return nextFrame;
|
||||
},
|
||||
window: dom.window,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const { resetPersistentAgentAudienceStore } = await import(
|
||||
"@/features/messages/lib/persistentAgentAudience.ts"
|
||||
);
|
||||
resetPersistentAgentAudienceStore();
|
||||
frames = new Map();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { cleanup } = await import("@testing-library/react");
|
||||
cleanup();
|
||||
frames = new Map();
|
||||
});
|
||||
|
||||
after(() => dom.window.close());
|
||||
|
||||
async function renderRestoreHook(initialProps) {
|
||||
const { renderHook } = await import("@testing-library/react");
|
||||
const { useAddressedAgentMentionRestore } = await import(
|
||||
"./useAddressedAgentMentionRestore.ts"
|
||||
);
|
||||
return renderHook((props) => useAddressedAgentMentionRestore(props), {
|
||||
initialProps,
|
||||
});
|
||||
}
|
||||
|
||||
test("does not restore the cleared composer after a scope transition", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const rootScope = "owner:channel:channel";
|
||||
const threadScope = "owner:channel:thread";
|
||||
const restored = [];
|
||||
const { result, rerender } = await renderRestoreHook({
|
||||
audienceScope: rootScope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
result.current.restoreAddressedAgentMentionsRef.current = (...args) => {
|
||||
restored.push(args);
|
||||
return "@Agent ";
|
||||
};
|
||||
const capturedClear = result.current.onAddressedAgentsComposerCleared;
|
||||
|
||||
act(() => {
|
||||
rerender({
|
||||
audienceScope: threadScope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
const restoredText = capturedClear([AGENT_A]);
|
||||
|
||||
assert.equal(restoredText, "");
|
||||
assert.deepEqual(restored, []);
|
||||
});
|
||||
|
||||
test("does not restore the cleared composer after leaving and returning to its scope", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const rootScope = "owner:channel:channel";
|
||||
const threadScope = "owner:channel:thread";
|
||||
const restored = [];
|
||||
const { result, rerender } = await renderRestoreHook({
|
||||
audienceScope: rootScope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
result.current.restoreAddressedAgentMentionsRef.current = (...args) => {
|
||||
restored.push(args);
|
||||
return "@Agent ";
|
||||
};
|
||||
const capturedClear = result.current.onAddressedAgentsComposerCleared;
|
||||
|
||||
act(() => {
|
||||
rerender({
|
||||
audienceScope: threadScope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
rerender({
|
||||
audienceScope: rootScope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
const restoredText = capturedClear([AGENT_A]);
|
||||
|
||||
assert.equal(restoredText, "");
|
||||
assert.deepEqual(restored, []);
|
||||
});
|
||||
|
||||
test("does not restore the cleared composer after unmount", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const scope = "owner:channel:channel";
|
||||
const restored = [];
|
||||
const { result, unmount } = await renderRestoreHook({
|
||||
audienceScope: scope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
result.current.restoreAddressedAgentMentionsRef.current = (...args) => {
|
||||
restored.push(args);
|
||||
return "@Agent ";
|
||||
};
|
||||
const capturedClear = result.current.onAddressedAgentsComposerCleared;
|
||||
|
||||
act(() => unmount());
|
||||
const restoredText = capturedClear([AGENT_A]);
|
||||
|
||||
assert.equal(restoredText, "");
|
||||
assert.deepEqual(restored, []);
|
||||
});
|
||||
|
||||
test("restores the cleared composer when its audience is current", async () => {
|
||||
const scope = "owner:channel:channel";
|
||||
const { setPersistentAgentAudience } = await import(
|
||||
"@/features/messages/lib/persistentAgentAudience.ts"
|
||||
);
|
||||
setPersistentAgentAudience(scope, [AGENT_A]);
|
||||
const restored = [];
|
||||
const { result } = await renderRestoreHook({
|
||||
audienceScope: scope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
result.current.restoreAddressedAgentMentionsRef.current = (...args) => {
|
||||
restored.push(args);
|
||||
return "@Agent A ";
|
||||
};
|
||||
|
||||
const restoredText = result.current.onAddressedAgentsComposerCleared([
|
||||
AGENT_A,
|
||||
]);
|
||||
|
||||
assert.equal(restoredText, "@Agent A ");
|
||||
assert.deepEqual(restored, [[[AGENT_A]]]);
|
||||
});
|
||||
|
||||
test("clearing after same-tick removal restores only remaining agents", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const scope = "owner:channel:channel";
|
||||
const { excludePersistentAgentAudienceMember, setPersistentAgentAudience } =
|
||||
await import("@/features/messages/lib/persistentAgentAudience.ts");
|
||||
setPersistentAgentAudience(scope, [AGENT_A, AGENT_B]);
|
||||
const restored = [];
|
||||
const { result } = await renderRestoreHook({
|
||||
audienceScope: scope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
result.current.restoreAddressedAgentMentionsRef.current = (...args) => {
|
||||
restored.push(args);
|
||||
return "@Agent B ";
|
||||
};
|
||||
const capturedClear = result.current.onAddressedAgentsComposerCleared;
|
||||
|
||||
let restoredText;
|
||||
act(() => {
|
||||
excludePersistentAgentAudienceMember(scope, AGENT_A);
|
||||
restoredText = capturedClear([AGENT_A, AGENT_B]);
|
||||
});
|
||||
|
||||
assert.equal(restoredText, "@Agent B ");
|
||||
assert.deepEqual(restored, [[[AGENT_B]]]);
|
||||
});
|
||||
|
||||
test("restores a successful send when its audience is still current", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const scope = "owner:channel:channel";
|
||||
const { setPersistentAgentAudience } = await import(
|
||||
"@/features/messages/lib/persistentAgentAudience.ts"
|
||||
);
|
||||
setPersistentAgentAudience(scope, [AGENT_A]);
|
||||
const restored = [];
|
||||
const { result } = await renderRestoreHook({
|
||||
audienceScope: scope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
result.current.restoreAddressedAgentMentionsRef.current = (...args) => {
|
||||
restored.push(args);
|
||||
return "";
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.onAddressedAgentsSendSucceeded([AGENT_A], [AGENT_A]);
|
||||
flushFrames();
|
||||
});
|
||||
|
||||
assert.deepEqual(restored, [[[AGENT_A]]]);
|
||||
});
|
||||
|
||||
test("does not restore after a same-tick audience removal", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const scope = "owner:channel:channel";
|
||||
const { excludePersistentAgentAudienceMember, setPersistentAgentAudience } =
|
||||
await import("@/features/messages/lib/persistentAgentAudience.ts");
|
||||
setPersistentAgentAudience(scope, [AGENT_A]);
|
||||
const restored = [];
|
||||
const { result } = await renderRestoreHook({
|
||||
audienceScope: scope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
result.current.restoreAddressedAgentMentionsRef.current = (...args) => {
|
||||
restored.push(args);
|
||||
return "";
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.onAddressedAgentsSendSucceeded([AGENT_A], [AGENT_A]);
|
||||
excludePersistentAgentAudienceMember(scope, AGENT_A);
|
||||
flushFrames();
|
||||
});
|
||||
|
||||
assert.deepEqual(restored, []);
|
||||
});
|
||||
|
||||
test("does not restore when success is invoked after removal", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const scope = "owner:channel:channel";
|
||||
const { excludePersistentAgentAudienceMember, setPersistentAgentAudience } =
|
||||
await import("@/features/messages/lib/persistentAgentAudience.ts");
|
||||
setPersistentAgentAudience(scope, [AGENT_A]);
|
||||
const restored = [];
|
||||
const { result, rerender } = await renderRestoreHook({
|
||||
audienceScope: scope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
result.current.restoreAddressedAgentMentionsRef.current = (...args) => {
|
||||
restored.push(args);
|
||||
return "";
|
||||
};
|
||||
const capturedSuccess = result.current.onAddressedAgentsSendSucceeded;
|
||||
|
||||
act(() => {
|
||||
excludePersistentAgentAudienceMember(scope, AGENT_A);
|
||||
rerender({
|
||||
audienceScope: scope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
capturedSuccess([AGENT_A], [AGENT_A]);
|
||||
flushFrames();
|
||||
});
|
||||
|
||||
assert.deepEqual(restored, []);
|
||||
});
|
||||
|
||||
test("does not restore after automatic mentions are turned off", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const scope = "owner:channel:channel";
|
||||
const { setPersistentAgentAudience } = await import(
|
||||
"@/features/messages/lib/persistentAgentAudience.ts"
|
||||
);
|
||||
setPersistentAgentAudience(scope, [AGENT_A]);
|
||||
const restored = [];
|
||||
const { result, rerender } = await renderRestoreHook({
|
||||
audienceScope: scope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
result.current.restoreAddressedAgentMentionsRef.current = (...args) => {
|
||||
restored.push(args);
|
||||
return "";
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.onAddressedAgentsSendSucceeded([AGENT_A], [AGENT_A]);
|
||||
});
|
||||
act(() => {
|
||||
rerender({
|
||||
audienceScope: scope,
|
||||
channelId: "channel",
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
flushFrames();
|
||||
});
|
||||
|
||||
assert.deepEqual(restored, []);
|
||||
});
|
||||
|
||||
test("does not restore after the composer unmounts", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const scope = "owner:channel:channel";
|
||||
const { setPersistentAgentAudience } = await import(
|
||||
"@/features/messages/lib/persistentAgentAudience.ts"
|
||||
);
|
||||
setPersistentAgentAudience(scope, [AGENT_A]);
|
||||
const restored = [];
|
||||
const { result, unmount } = await renderRestoreHook({
|
||||
audienceScope: scope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
result.current.restoreAddressedAgentMentionsRef.current = (...args) => {
|
||||
restored.push(args);
|
||||
return "";
|
||||
};
|
||||
|
||||
const capturedSuccess = result.current.onAddressedAgentsSendSucceeded;
|
||||
act(() => {
|
||||
capturedSuccess([AGENT_A], [AGENT_A]);
|
||||
unmount();
|
||||
capturedSuccess([AGENT_A], [AGENT_A]);
|
||||
flushFrames();
|
||||
});
|
||||
|
||||
assert.deepEqual(restored, []);
|
||||
});
|
||||
|
||||
test("does not restore after leaving and returning to the same scope", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const rootScope = "owner:channel:channel";
|
||||
const threadScope = "owner:channel:thread";
|
||||
const { setPersistentAgentAudience } = await import(
|
||||
"@/features/messages/lib/persistentAgentAudience.ts"
|
||||
);
|
||||
setPersistentAgentAudience(rootScope, [AGENT_A]);
|
||||
setPersistentAgentAudience(threadScope, [AGENT_B]);
|
||||
const restored = [];
|
||||
const { result, rerender } = await renderRestoreHook({
|
||||
audienceScope: rootScope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
result.current.restoreAddressedAgentMentionsRef.current = (...args) => {
|
||||
restored.push(args);
|
||||
return "";
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.onAddressedAgentsSendSucceeded([AGENT_A], [AGENT_A]);
|
||||
});
|
||||
act(() => {
|
||||
rerender({
|
||||
audienceScope: threadScope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
rerender({
|
||||
audienceScope: rootScope,
|
||||
channelId: "channel",
|
||||
enabled: true,
|
||||
});
|
||||
flushFrames();
|
||||
});
|
||||
|
||||
assert.deepEqual(restored, []);
|
||||
});
|
||||
@@ -1,61 +1,115 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
getPersistentAgentAudienceRevision,
|
||||
getPersistentAgentAudienceSnapshot,
|
||||
} from "@/features/messages/lib/persistentAgentAudience";
|
||||
|
||||
type RestoreAddressedAgentMentions = (
|
||||
pubkeys?: readonly string[],
|
||||
allowedUnpinnedPubkeys?: readonly string[],
|
||||
) => string;
|
||||
|
||||
export function useAddressedAgentMentionRestore({
|
||||
audiencePubkeys,
|
||||
audienceScope,
|
||||
channelId,
|
||||
enabled,
|
||||
}: {
|
||||
audiencePubkeys: readonly string[];
|
||||
audienceScope: string | null;
|
||||
channelId: string | null;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const restoreAddressedAgentMentionsRef =
|
||||
React.useRef<RestoreAddressedAgentMentions>(() => "");
|
||||
const restoreFrameRef = React.useRef<number | null>(null);
|
||||
const audienceScopeRef = React.useRef(audienceScope);
|
||||
const audienceScopeGenerationRef = React.useRef(0);
|
||||
if (audienceScopeRef.current !== audienceScope) {
|
||||
audienceScopeRef.current = audienceScope;
|
||||
audienceScopeGenerationRef.current += 1;
|
||||
}
|
||||
const renderAudienceScopeGeneration = audienceScopeGenerationRef.current;
|
||||
const channelIdRef = React.useRef(channelId);
|
||||
channelIdRef.current = channelId;
|
||||
const enabledRef = React.useRef(enabled);
|
||||
enabledRef.current = enabled;
|
||||
const isMountedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
React.useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (restoreFrameRef.current !== null) {
|
||||
cancelAnimationFrame(restoreFrameRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onAddressedAgentsComposerCleared = React.useCallback(
|
||||
(pubkeys: readonly string[]) =>
|
||||
restoreAddressedAgentMentionsRef.current(pubkeys),
|
||||
[],
|
||||
(pubkeys: readonly string[]) => {
|
||||
const clearedAudienceScope = audienceScope;
|
||||
const clearedAudienceScopeGeneration = renderAudienceScopeGeneration;
|
||||
if (
|
||||
!isMountedRef.current ||
|
||||
!clearedAudienceScope ||
|
||||
audienceScopeRef.current !== clearedAudienceScope ||
|
||||
audienceScopeGenerationRef.current !== clearedAudienceScopeGeneration
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
const currentAudience = new Set(
|
||||
getPersistentAgentAudienceSnapshot().audiences[clearedAudienceScope] ??
|
||||
[],
|
||||
);
|
||||
return restoreAddressedAgentMentionsRef.current(
|
||||
pubkeys.filter((pubkey) => currentAudience.has(pubkey)),
|
||||
);
|
||||
},
|
||||
[audienceScope, renderAudienceScopeGeneration],
|
||||
);
|
||||
const onAddressedAgentsSendSucceeded = React.useCallback(
|
||||
(pubkeys: readonly string[], newlyPinnedPubkeys: readonly string[]) => {
|
||||
const currentAudience = new Set(audiencePubkeys);
|
||||
const confirmedPinnedPubkeys = newlyPinnedPubkeys.filter((pubkey) =>
|
||||
currentAudience.has(pubkey),
|
||||
);
|
||||
if (!enabled || confirmedPinnedPubkeys.length === 0) return;
|
||||
const sentAudienceScope = audienceScope;
|
||||
if (
|
||||
!isMountedRef.current ||
|
||||
!enabled ||
|
||||
!sentAudienceScope ||
|
||||
newlyPinnedPubkeys.length === 0
|
||||
)
|
||||
return;
|
||||
|
||||
const sentChannelId = channelId;
|
||||
const sentAudienceScopeGeneration = renderAudienceScopeGeneration;
|
||||
const sentAudienceRevision =
|
||||
getPersistentAgentAudienceRevision(sentAudienceScope);
|
||||
if (restoreFrameRef.current !== null) {
|
||||
cancelAnimationFrame(restoreFrameRef.current);
|
||||
}
|
||||
restoreFrameRef.current = requestAnimationFrame(() => {
|
||||
restoreFrameRef.current = null;
|
||||
if (channelIdRef.current !== sentChannelId) return;
|
||||
if (
|
||||
!enabledRef.current ||
|
||||
audienceScopeRef.current !== sentAudienceScope ||
|
||||
audienceScopeGenerationRef.current !== sentAudienceScopeGeneration ||
|
||||
channelIdRef.current !== sentChannelId ||
|
||||
getPersistentAgentAudienceRevision(sentAudienceScope) !==
|
||||
sentAudienceRevision
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentAudience = new Set(
|
||||
getPersistentAgentAudienceSnapshot().audiences[sentAudienceScope] ??
|
||||
[],
|
||||
);
|
||||
if (!newlyPinnedPubkeys.some((pubkey) => currentAudience.has(pubkey)))
|
||||
return;
|
||||
restoreAddressedAgentMentionsRef.current(
|
||||
pubkeys,
|
||||
confirmedPinnedPubkeys,
|
||||
pubkeys.filter((pubkey) => currentAudience.has(pubkey)),
|
||||
);
|
||||
});
|
||||
},
|
||||
[audiencePubkeys, channelId, enabled],
|
||||
[audienceScope, channelId, enabled, renderAudienceScopeGeneration],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -617,12 +617,14 @@ test("deleting human mentions is ignored while deleting a restored automatic age
|
||||
assert.deepEqual(removedPubkeys, []);
|
||||
});
|
||||
|
||||
test("selecting an agent from the explicit picker auto-addresses it", async () => {
|
||||
test("selecting an agent from the toolbar respects the same auto-pin callback as a typed query", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const appliedEdits = [];
|
||||
const autoPinnedSuggestions = [];
|
||||
const explicitlyAddressed = [];
|
||||
const addedPubkeys = [];
|
||||
const pulsedPubkeys = [];
|
||||
const mentions = {
|
||||
@@ -651,6 +653,10 @@ test("selecting an agent from the explicit picker auto-addresses it", async () =
|
||||
audience,
|
||||
audienceScope: "channel-scope",
|
||||
mentions,
|
||||
onAutoPinAgentMention: (suggestion, options) =>
|
||||
autoPinnedSuggestions.push([suggestion, options]),
|
||||
onAddressAgentMention: (suggestion) =>
|
||||
explicitlyAddressed.push(suggestion),
|
||||
onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey),
|
||||
richText,
|
||||
}),
|
||||
@@ -671,15 +677,23 @@ test("selecting an agent from the explicit picker auto-addresses it", async () =
|
||||
insertText: "@Agent Ada ",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(addedPubkeys, ["agent-pubkey"]);
|
||||
assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]);
|
||||
assert.equal(
|
||||
result.current.announcement,
|
||||
"Automatically mentioning Agent Ada",
|
||||
);
|
||||
assert.deepEqual(addedPubkeys, []);
|
||||
assert.deepEqual(pulsedPubkeys, []);
|
||||
assert.deepEqual(explicitlyAddressed, []);
|
||||
assert.deepEqual(autoPinnedSuggestions, [
|
||||
[
|
||||
{
|
||||
pubkey: "agent-pubkey",
|
||||
displayName: "Agent Ada",
|
||||
isAgent: true,
|
||||
},
|
||||
{ reinstateExcluded: true },
|
||||
],
|
||||
]);
|
||||
assert.equal(result.current.announcement, "");
|
||||
});
|
||||
|
||||
test("repeatedly selecting an explicitly unpinned agent keeps its mentions manual", async () => {
|
||||
test("explicitly re-adding a removed agent follows the current automation preference", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
@@ -775,7 +789,7 @@ test("repeatedly selecting an explicitly unpinned agent keeps its mentions manua
|
||||
displayName: "Agent Ada",
|
||||
isAgent: true,
|
||||
},
|
||||
{ reinstateExcluded: false },
|
||||
{ reinstateExcluded: true },
|
||||
],
|
||||
[
|
||||
{
|
||||
@@ -783,7 +797,7 @@ test("repeatedly selecting an explicitly unpinned agent keeps its mentions manua
|
||||
displayName: "Agent Ada",
|
||||
isAgent: true,
|
||||
},
|
||||
{ reinstateExcluded: false },
|
||||
{ reinstateExcluded: true },
|
||||
],
|
||||
]);
|
||||
assert.deepEqual(pulsedPubkeys, []);
|
||||
@@ -865,3 +879,61 @@ test("an addressed agent keeps its resolved name while mention state clears duri
|
||||
|
||||
assert.equal(result.current.lockedAgents[0].displayName, "Agent Ada");
|
||||
});
|
||||
|
||||
for (const text of [
|
||||
"@Agent Ada @Agent Bea hello",
|
||||
"hello @Agent Bea and @Agent Ada then @Agent Bea",
|
||||
]) {
|
||||
test(`avatar removal strips every occurrence, not only a prefix: ${text}`, async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const { getMentionOffsets } = await import("../lib/hasMention.ts");
|
||||
let currentText = text;
|
||||
const excluded = [];
|
||||
const refs = [
|
||||
{ displayName: "Agent Ada", pubkey: "agent-a", isAgent: true },
|
||||
{ displayName: "Agent Bea", pubkey: "agent-b", isAgent: true },
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit: ({
|
||||
replaceFromOffset,
|
||||
replaceToOffset,
|
||||
insertText,
|
||||
}) => {
|
||||
currentText =
|
||||
currentText.slice(0, replaceFromOffset) +
|
||||
insertText +
|
||||
currentText.slice(replaceToOffset);
|
||||
},
|
||||
audience: {
|
||||
pubkeys: ["agent-a", "agent-b"],
|
||||
excludePubkey: (key) => excluded.push(key),
|
||||
},
|
||||
audienceScope: "channel",
|
||||
mentions: {
|
||||
getMentionDisplayName: (key) =>
|
||||
refs.find((ref) => ref.pubkey === key)?.displayName,
|
||||
getDraftMentionRefs: (value) =>
|
||||
refs.filter(
|
||||
(ref) => getMentionOffsets(value, ref.displayName).length > 0,
|
||||
),
|
||||
},
|
||||
onPulseAddressLock: () => {},
|
||||
richText: {
|
||||
getPlainTextAndCursor: () => ({
|
||||
text: currentText,
|
||||
cursor: currentText.length,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
act(() => result.current.removeAddressedAgent("agent-b"));
|
||||
assert.deepEqual(excluded, ["agent-b"]);
|
||||
assert.equal(getMentionOffsets(currentText, "Agent Bea").length, 0);
|
||||
assert.equal(getMentionOffsets(currentText, "Agent Ada").length, 1);
|
||||
assert.ok(currentText.includes("hello"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { getMentionOffsets } from "@/features/messages/lib/hasMention";
|
||||
import { stripImplicitAgentMentionPrefix } from "@/features/messages/lib/stripImplicitAgentMentions";
|
||||
import type { usePersistentAgentAudience } from "@/features/messages/lib/persistentAgentAudience";
|
||||
import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
|
||||
import type {
|
||||
@@ -184,24 +183,19 @@ export function useAgentAddressLockPicker({
|
||||
unpinnedAgentPubkeysRef.current.add(normalized);
|
||||
const excludePubkey = audience.excludePubkey ?? audience.removePubkey;
|
||||
excludePubkey(normalized);
|
||||
const displayName = lockedAgents.find(
|
||||
(agent) => agent.pubkey === normalized,
|
||||
)?.displayName;
|
||||
if (displayName) {
|
||||
const text = richText.getPlainTextAndCursor().text;
|
||||
const implicitPrefix = `@${displayName}${text === `@${displayName}` ? "" : " "}`;
|
||||
const strippedText = stripImplicitAgentMentionPrefix(
|
||||
text,
|
||||
implicitPrefix,
|
||||
);
|
||||
if (strippedText !== text) {
|
||||
onImplicitPrefixRemoved?.(normalized);
|
||||
applyAutocompleteEdit({
|
||||
replaceFromOffset: 0,
|
||||
replaceToOffset: text.length - strippedText.length,
|
||||
insertText: "",
|
||||
});
|
||||
}
|
||||
const { text } = richText.getPlainTextAndCursor();
|
||||
const matchingDisplayNames = mentions
|
||||
.getDraftMentionRefs(text)
|
||||
.filter((ref) => normalizePubkey(ref.pubkey) === normalized)
|
||||
.map((ref) => ref.displayName);
|
||||
const displayName =
|
||||
mentions.getMentionDisplayName(normalized) ??
|
||||
lockedAgentNamesRef.current.get(normalized);
|
||||
if (displayName) matchingDisplayNames.push(displayName);
|
||||
onImplicitPrefixRemoved?.(normalized);
|
||||
visibleAgentMentionPubkeysRef.current.delete(normalized);
|
||||
for (const edit of buildMentionRemovalEdits(text, matchingDisplayNames)) {
|
||||
applyAutocompleteEdit(edit);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -209,30 +203,9 @@ export function useAgentAddressLockPicker({
|
||||
audience.excludePubkey,
|
||||
audience.removePubkey,
|
||||
audienceScope,
|
||||
lockedAgents,
|
||||
onImplicitPrefixRemoved,
|
||||
richText.getPlainTextAndCursor,
|
||||
],
|
||||
);
|
||||
const removeAddressedAgentMentions = React.useCallback(
|
||||
(pubkey: string) => {
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
if (!audienceScope || !normalized) return;
|
||||
const { text } = richText.getPlainTextAndCursor();
|
||||
const matchingDisplayNames = mentions
|
||||
.getDraftMentionRefs(text)
|
||||
.filter((ref) => normalizePubkey(ref.pubkey) === normalized)
|
||||
.map((ref) => ref.displayName);
|
||||
for (const edit of buildMentionRemovalEdits(text, matchingDisplayNames)) {
|
||||
applyAutocompleteEdit(edit);
|
||||
}
|
||||
removeAddressedAgent(normalized);
|
||||
},
|
||||
[
|
||||
applyAutocompleteEdit,
|
||||
audienceScope,
|
||||
mentions.getDraftMentionRefs,
|
||||
removeAddressedAgent,
|
||||
mentions.getMentionDisplayName,
|
||||
onImplicitPrefixRemoved,
|
||||
richText.getPlainTextAndCursor,
|
||||
],
|
||||
);
|
||||
@@ -242,7 +215,7 @@ export function useAgentAddressLockPicker({
|
||||
if (!audienceScope || !pubkey || !suggestion.isAgent) return;
|
||||
|
||||
if (lockedAgentPubkeys.has(pubkey)) {
|
||||
removeAddressedAgentMentions(pubkey);
|
||||
removeAddressedAgent(pubkey);
|
||||
setAnnouncement(
|
||||
`Stopped automatically mentioning ${suggestion.displayName}`,
|
||||
);
|
||||
@@ -313,7 +286,7 @@ export function useAgentAddressLockPicker({
|
||||
onAddressAgentMention,
|
||||
onImplicitPrefixInserted,
|
||||
onPulseAddressLock,
|
||||
removeAddressedAgentMentions,
|
||||
removeAddressedAgent,
|
||||
richText.getPlainTextAndCursor,
|
||||
trackMentionAddressedAgent,
|
||||
],
|
||||
@@ -322,47 +295,22 @@ export function useAgentAddressLockPicker({
|
||||
const selectMentionSuggestion = React.useCallback(
|
||||
(suggestion: MentionSuggestion) => {
|
||||
const pubkey = normalizePubkey(suggestion.pubkey ?? "");
|
||||
if (suggestion.isAgent && pubkey && audienceScope) {
|
||||
const { cursor } = richText.getPlainTextAndCursor();
|
||||
const wasUnpinned =
|
||||
!lockedAgentPubkeys.has(pubkey) &&
|
||||
unpinnedAgentPubkeysRef.current.has(pubkey);
|
||||
if (mentions.isInlineMentionSelection() || wasUnpinned) {
|
||||
applyAutocompleteEdit(mentions.insertMention(suggestion, cursor));
|
||||
trackMentionAddressedAgent(pubkey);
|
||||
onAutoPinAgentMention?.(suggestion, {
|
||||
reinstateExcluded: !wasUnpinned,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
applyAutocompleteEdit(mentions.insertMention(suggestion, cursor));
|
||||
if (!lockedAgentPubkeys.has(pubkey)) {
|
||||
trackMentionAddressedAgent(pubkey);
|
||||
if (onAddressAgentMention) {
|
||||
onAddressAgentMention(suggestion);
|
||||
} else {
|
||||
audience.addPubkey(pubkey);
|
||||
onPulseAddressLock(pubkey);
|
||||
}
|
||||
setAnnouncement(`Automatically mentioning ${suggestion.displayName}`);
|
||||
} else {
|
||||
onPulseAddressLock(pubkey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { cursor } = richText.getPlainTextAndCursor();
|
||||
applyAutocompleteEdit(mentions.insertMention(suggestion, cursor));
|
||||
if (suggestion.isAgent && pubkey && audienceScope) {
|
||||
// Adding a mention has the same meaning from the toolbar or typed @.
|
||||
// Only the separate pin action may enable automatic mentioning.
|
||||
unpinnedAgentPubkeysRef.current.delete(pubkey);
|
||||
trackMentionAddressedAgent(pubkey);
|
||||
onAutoPinAgentMention?.(suggestion, { reinstateExcluded: true });
|
||||
if (lockedAgentPubkeys.has(pubkey)) onPulseAddressLock(pubkey);
|
||||
}
|
||||
},
|
||||
[
|
||||
applyAutocompleteEdit,
|
||||
audience.addPubkey,
|
||||
audienceScope,
|
||||
lockedAgentPubkeys,
|
||||
mentions.isInlineMentionSelection,
|
||||
mentions.insertMention,
|
||||
onAddressAgentMention,
|
||||
onAutoPinAgentMention,
|
||||
onPulseAddressLock,
|
||||
richText.getPlainTextAndCursor,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, afterEach, before, test } from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
before(() =>
|
||||
Object.assign(globalThis, {
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
Element: dom.window.Element,
|
||||
Node: dom.window.Node,
|
||||
getComputedStyle: dom.window.getComputedStyle,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
window: dom.window,
|
||||
}),
|
||||
);
|
||||
afterEach(async () => (await import("@testing-library/react")).cleanup());
|
||||
after(() => dom.window.close());
|
||||
|
||||
test("recipient preview reacts to editor and audience changes without rerendering on ordinary typing", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { Editor } = await import("@tiptap/core");
|
||||
const { default: StarterKit } = await import("@tiptap/starter-kit");
|
||||
const { useComposerAgentRecipients } = await import(
|
||||
"./useComposerAgentRecipients.ts"
|
||||
);
|
||||
const { extractMentionPubkeys } = await import(
|
||||
"../lib/extractMentionPubkeys.ts"
|
||||
);
|
||||
const agentA = "a".repeat(64);
|
||||
const agentB = "b".repeat(64);
|
||||
const human = "c".repeat(64);
|
||||
const candidates = [
|
||||
{ displayName: "Ada", pubkey: agentA, isMember: true },
|
||||
{ displayName: "Bea", pubkey: agentB, isMember: true },
|
||||
{ displayName: "Human", pubkey: human, isMember: true },
|
||||
];
|
||||
const editor = new Editor({
|
||||
extensions: [StarterKit],
|
||||
content: "<p>@Ada hello</p>",
|
||||
});
|
||||
let serializations = 0;
|
||||
let renders = 0;
|
||||
const mentions = {
|
||||
getDraftMentionRefs: () => [],
|
||||
extractMentionPubkeys: (text) =>
|
||||
extractMentionPubkeys({
|
||||
text,
|
||||
selectedMentions: new Map(),
|
||||
memberCandidates: candidates,
|
||||
}),
|
||||
isAgentPubkey: (key) => key === agentA || key === agentB,
|
||||
getMentionDisplayName: (key) =>
|
||||
candidates.find((candidate) => candidate.pubkey === key)?.displayName,
|
||||
};
|
||||
const richText = {
|
||||
editor,
|
||||
getMarkdown: () => {
|
||||
serializations += 1;
|
||||
return editor.getText();
|
||||
},
|
||||
};
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ audience }) => {
|
||||
renders += 1;
|
||||
return useComposerAgentRecipients({
|
||||
audiencePubkeys: audience,
|
||||
mentions,
|
||||
richText,
|
||||
});
|
||||
},
|
||||
{ initialProps: { audience: [] } },
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.current.map(({ pubkey, isAutomatic }) => [pubkey, isAutomatic]),
|
||||
[[agentA, false]],
|
||||
);
|
||||
const beforeTyping = renders;
|
||||
act(() => {
|
||||
editor.commands.insertContentAt(
|
||||
editor.state.doc.content.size - 1,
|
||||
" world",
|
||||
);
|
||||
});
|
||||
assert.equal(renders, beforeTyping);
|
||||
const beforeSelection = serializations;
|
||||
act(() => {
|
||||
editor.commands.setTextSelection(1);
|
||||
});
|
||||
assert.equal(serializations, beforeSelection);
|
||||
act(() => {
|
||||
editor.commands.setContent("<p>@Ada @Bea @Human</p>");
|
||||
});
|
||||
assert.deepEqual(
|
||||
result.current.map(({ pubkey }) => pubkey),
|
||||
[agentA, agentB],
|
||||
);
|
||||
rerender({ audience: [agentB] });
|
||||
assert.equal(
|
||||
result.current.find(({ pubkey }) => pubkey === agentB).isAutomatic,
|
||||
true,
|
||||
);
|
||||
act(() => {
|
||||
editor.commands.setContent("<p>@Human</p>");
|
||||
});
|
||||
assert.deepEqual(
|
||||
result.current.map(({ pubkey }) => pubkey),
|
||||
[agentB],
|
||||
);
|
||||
rerender({ audience: [] });
|
||||
assert.deepEqual(result.current, []);
|
||||
unmount();
|
||||
editor.destroy();
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as React from "react";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
|
||||
import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
|
||||
import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import type { ComposerAddressAgent } from "./ComposerAddressControls";
|
||||
import { mergeMentionRecipients } from "./useMentionSendFlow.helpers";
|
||||
|
||||
/** Preview the same resolved identities used at the send boundary. */
|
||||
export function useComposerAgentRecipients({
|
||||
audiencePubkeys,
|
||||
mentions,
|
||||
profiles,
|
||||
richText,
|
||||
}: {
|
||||
audiencePubkeys: readonly string[];
|
||||
mentions: UseMentionsResult;
|
||||
profiles?: UserProfileLookup;
|
||||
richText: UseRichTextEditorResult;
|
||||
}): ComposerAddressAgent[] {
|
||||
const serializedRef = React.useRef<{
|
||||
document: unknown;
|
||||
text: string;
|
||||
}>({ document: null, text: "" });
|
||||
return (
|
||||
useEditorState({
|
||||
editor: richText.editor,
|
||||
selector: ({ editor }) => {
|
||||
const document = editor?.state.doc;
|
||||
// Do not serialize on selection transactions or rerender the composer
|
||||
// for ordinary typing. useEditorState compares the recipient rows.
|
||||
if (serializedRef.current.document !== document) {
|
||||
serializedRef.current = {
|
||||
document,
|
||||
text: document?.textContent.includes("@")
|
||||
? richText.getMarkdown()
|
||||
: "",
|
||||
};
|
||||
}
|
||||
const text = serializedRef.current.text;
|
||||
const refs = mentions.getDraftMentionRefs(text);
|
||||
const automatic = new Set(audiencePubkeys.map(normalizePubkey));
|
||||
const selectedAgents = new Set(
|
||||
refs
|
||||
.filter((ref) => ref.isAgent)
|
||||
.map((ref) => normalizePubkey(ref.pubkey)),
|
||||
);
|
||||
return mergeMentionRecipients(
|
||||
mentions.extractMentionPubkeys(text),
|
||||
audiencePubkeys,
|
||||
)
|
||||
.filter(
|
||||
(pubkey) =>
|
||||
automatic.has(pubkey) ||
|
||||
selectedAgents.has(pubkey) ||
|
||||
mentions.isAgentPubkey(pubkey),
|
||||
)
|
||||
.map((pubkey) => {
|
||||
const profile = profiles?.[pubkey];
|
||||
return {
|
||||
pubkey,
|
||||
displayName:
|
||||
refs.find((ref) => normalizePubkey(ref.pubkey) === pubkey)
|
||||
?.displayName ||
|
||||
mentions.getMentionDisplayName(pubkey) ||
|
||||
profile?.displayName ||
|
||||
profile?.name ||
|
||||
truncatePubkey(pubkey),
|
||||
avatarUrl: profile?.avatarUrl ?? null,
|
||||
isAutomatic: automatic.has(pubkey),
|
||||
};
|
||||
});
|
||||
},
|
||||
}) ?? []
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,29 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import {
|
||||
AGENT_A,
|
||||
AGENT_B,
|
||||
KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY,
|
||||
RANDOM_CHANNEL_ID,
|
||||
addMention,
|
||||
automaticallyMention,
|
||||
channelComposer,
|
||||
emitMockMessage,
|
||||
focusComposerEnd,
|
||||
installAudienceFixtures,
|
||||
keepMentionedAgentsPinned,
|
||||
openGeneral,
|
||||
openThread,
|
||||
pressPrimaryShiftM,
|
||||
readComposerCaret,
|
||||
readOutgoingMentionPubkeys,
|
||||
readPersistedDraft,
|
||||
seedTheme,
|
||||
threadComposer,
|
||||
} from "../helpers/persistentAgentAudience";
|
||||
|
||||
const SHOTS = "test-results/persistent-agent-audience";
|
||||
const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
|
||||
const AGENT_A = "a".repeat(64);
|
||||
const AGENT_B = "b".repeat(64);
|
||||
const THREAD_ROOT_ID = "mock-general-welcome";
|
||||
const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY =
|
||||
"buzz.messages.keepMentionedAgentsPinned";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((storageKey) => {
|
||||
@@ -18,181 +31,6 @@ test.beforeEach(async ({ page }) => {
|
||||
}, KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY);
|
||||
});
|
||||
|
||||
async function keepMentionedAgentsPinned(page: Page) {
|
||||
await page.addInitScript((storageKey) => {
|
||||
window.localStorage.setItem(storageKey, "true");
|
||||
}, KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY);
|
||||
}
|
||||
|
||||
async function seedTheme(page: Page, theme: string, accent = "#c0a2f1") {
|
||||
await page.addInitScript(
|
||||
({ selectedTheme, selectedAccent }) => {
|
||||
window.localStorage.setItem("buzz-theme", selectedTheme);
|
||||
window.localStorage.setItem("buzz-accent-color", selectedAccent);
|
||||
},
|
||||
{ selectedTheme: theme, selectedAccent: accent },
|
||||
);
|
||||
}
|
||||
|
||||
async function automaticallyMention(
|
||||
composer: ReturnType<typeof channelComposer>,
|
||||
displayName: string,
|
||||
) {
|
||||
await composer.locator("[data-mention-picker-trigger]").click();
|
||||
await composer
|
||||
.getByTestId("mention-autocomplete")
|
||||
.getByRole("button", { name: `Automatically mention ${displayName}` })
|
||||
.click();
|
||||
await expect(composer.getByTestId("message-input")).toContainText(
|
||||
`@${displayName}`,
|
||||
);
|
||||
await composer.locator("[data-mention-picker-trigger]").click();
|
||||
}
|
||||
|
||||
async function openGeneral(page: Page) {
|
||||
await page.goto(`/#/channels/${CHANNEL_ID}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function channelComposer(page: Page) {
|
||||
return page.getByTestId("channel-composer-overlay");
|
||||
}
|
||||
|
||||
function threadComposer(page: Page) {
|
||||
return page.getByTestId("thread-composer-overlay");
|
||||
}
|
||||
|
||||
async function readComposerCaret(input: Locator) {
|
||||
return input.evaluate((element) => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.anchorNode || !element.contains(selection.anchorNode)) {
|
||||
return null;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
range.setEnd(selection.anchorNode, selection.anchorOffset);
|
||||
return range.toString().length;
|
||||
});
|
||||
}
|
||||
|
||||
async function pressPrimaryShiftM(page: Page) {
|
||||
const isMac = await page.evaluate(() =>
|
||||
/mac|iphone|ipad|ipod/i.test(navigator.platform),
|
||||
);
|
||||
await page.keyboard.press(`${isMac ? "Meta" : "Control"}+Shift+M`);
|
||||
}
|
||||
|
||||
async function readOutgoingMentionPubkeys(page: Page, content: string) {
|
||||
return page.evaluate((expectedContent) => {
|
||||
const signedEvent = window.__BUZZ_E2E_SIGNED_EVENTS__?.find(
|
||||
(event) => event.content === expectedContent,
|
||||
);
|
||||
if (signedEvent) {
|
||||
return (signedEvent.tags ?? [])
|
||||
.filter((tag) => tag[0] === "p" && tag[1])
|
||||
.map((tag) => tag[1]);
|
||||
}
|
||||
|
||||
for (const entry of window.__BUZZ_E2E_COMMAND_LOG__ ?? []) {
|
||||
if (entry.command === "send_channel_message") {
|
||||
const payload = entry.payload as
|
||||
| { content?: string; mentionPubkeys?: string[] }
|
||||
| undefined;
|
||||
if (payload?.content === expectedContent) {
|
||||
return payload.mentionPubkeys ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.command !== "plugin:websocket|send") continue;
|
||||
const data = (
|
||||
entry.payload as { message?: { data?: string } } | undefined
|
||||
)?.message?.data;
|
||||
if (!data) continue;
|
||||
|
||||
try {
|
||||
const frame = JSON.parse(data) as [
|
||||
string,
|
||||
{ content?: string; tags?: string[][] },
|
||||
];
|
||||
if (frame[0] !== "EVENT" || frame[1]?.content !== expectedContent) {
|
||||
continue;
|
||||
}
|
||||
return (frame[1].tags ?? [])
|
||||
.filter((tag) => tag[0] === "p" && tag[1])
|
||||
.map((tag) => tag[1]);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, content);
|
||||
}
|
||||
|
||||
async function emitMockMessage(
|
||||
page: Page,
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ body, mentions }) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: body,
|
||||
mentionPubkeys: mentions,
|
||||
});
|
||||
},
|
||||
{ body: content, mentions: mentionPubkeys },
|
||||
);
|
||||
}
|
||||
|
||||
async function installAudienceFixtures(
|
||||
page: Page,
|
||||
options: {
|
||||
agentAName?: string;
|
||||
deferredComposerUploads?: boolean;
|
||||
sendMessageDelayMs?: number;
|
||||
sendMessageErrors?: string[];
|
||||
uploadDelayMs?: number;
|
||||
uploadDescriptors?: Array<{
|
||||
filename: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
type: string;
|
||||
uploaded: number;
|
||||
url: string;
|
||||
}>;
|
||||
usersBatchDelayMs?: number;
|
||||
} = {},
|
||||
) {
|
||||
const { agentAName = "Morgarita", ...bridgeOptions } = options;
|
||||
await installMockBridge(page, {
|
||||
...bridgeOptions,
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: AGENT_A,
|
||||
name: agentAName,
|
||||
status: "running",
|
||||
channelNames: ["general"],
|
||||
},
|
||||
{
|
||||
pubkey: AGENT_B,
|
||||
name: "Vogue",
|
||||
status: "running",
|
||||
channelNames: ["general"],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
test("keeps a queued-attachment send locked through upload and send settlement", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -267,6 +105,218 @@ test("keeps a queued-attachment send locked through upload and send settlement",
|
||||
await expect(composerForm).toHaveAttribute("data-submit-locked", "false");
|
||||
});
|
||||
|
||||
for (const surface of ["channel", "thread"] as const) {
|
||||
for (const automatic of [false, true]) {
|
||||
for (const mode of ["toolbar", "typed"] as const) {
|
||||
test(`${surface}: ${mode} mentions respect auto=${automatic} and second-avatar removal`, async ({
|
||||
page,
|
||||
}) => {
|
||||
if (automatic) await keepMentionedAgentsPinned(page);
|
||||
await installAudienceFixtures(page);
|
||||
await (surface === "channel" ? openGeneral(page) : openThread(page));
|
||||
const composer =
|
||||
surface === "channel" ? channelComposer(page) : threadComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await addMention(composer, "Morgarita", mode);
|
||||
await addMention(composer, "Vogue", mode);
|
||||
await input.pressSequentially("review ");
|
||||
await addMention(composer, "Vogue", mode);
|
||||
await input.pressSequentially("now");
|
||||
await expect(input).toHaveText("@Morgarita @Vogue review @Vogue now");
|
||||
await expect(
|
||||
composer.getByTestId("composer-address-lock-avatar"),
|
||||
).toHaveCount(2);
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_B}`),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
(key) => localStorage.getItem(key) === "true",
|
||||
KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY,
|
||||
),
|
||||
).toBe(automatic);
|
||||
await composer
|
||||
.getByRole("button", {
|
||||
name: "Remove Vogue from this and future messages",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(input).toHaveText("@Morgarita review now");
|
||||
await expect(
|
||||
composer.getByTestId("composer-address-lock-avatar"),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_B}`),
|
||||
).toHaveCount(0);
|
||||
await input.press("Enter");
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@Morgarita review now"))
|
||||
.toEqual([AGENT_A]);
|
||||
await expect(composer.getByTestId("message-composer")).toHaveAttribute(
|
||||
"data-submit-locked",
|
||||
"false",
|
||||
);
|
||||
await expect(input).toHaveText(automatic ? "@Morgarita " : "");
|
||||
await expect(
|
||||
composer.getByTestId("composer-address-lock-avatar"),
|
||||
).toHaveCount(automatic ? 1 : 0);
|
||||
await input.pressSequentially("follow-up");
|
||||
await input.press("Enter");
|
||||
await expect
|
||||
.poll(() =>
|
||||
readOutgoingMentionPubkeys(
|
||||
page,
|
||||
automatic ? "@Morgarita follow-up" : "follow-up",
|
||||
),
|
||||
)
|
||||
.toEqual(automatic ? [AGENT_A] : []);
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_B}`),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("an upload finishing cannot restore an agent removed from the next draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
await keepMentionedAgentsPinned(page);
|
||||
await installAudienceFixtures(page, {
|
||||
deferredComposerUploads: true,
|
||||
uploadDelayMs: 3_500,
|
||||
uploadDescriptors: [
|
||||
{
|
||||
filename: "audience.mp4",
|
||||
sha256: "c".repeat(64),
|
||||
size: 16,
|
||||
type: "video/mp4",
|
||||
uploaded: 1,
|
||||
url: `https://mock.relay/media/${"c".repeat(64)}.mp4`,
|
||||
},
|
||||
],
|
||||
});
|
||||
await openThread(page);
|
||||
const composer = threadComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await addMention(composer, "Morgarita", "typed");
|
||||
await addMention(composer, "Vogue", "typed");
|
||||
await input.pressSequentially("queued review");
|
||||
const [chooser] = await Promise.all([
|
||||
page.waitForEvent("filechooser"),
|
||||
composer.getByRole("button", { name: "Attach file" }).click(),
|
||||
]);
|
||||
await chooser.setFiles({
|
||||
buffer: Buffer.from("queued video"),
|
||||
mimeType: "video/mp4",
|
||||
name: "audience.mp4",
|
||||
});
|
||||
await input.press("Enter");
|
||||
await expect(composer.getByTestId("composer-upload-progress")).toBeVisible();
|
||||
await expect(input).toHaveText("@Morgarita @Vogue ");
|
||||
// Remove the leading mention so this catches stale restoration independently
|
||||
// of the second-avatar removal bug covered by the matrix above.
|
||||
await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click();
|
||||
await expect(input).toHaveText("@Vogue ");
|
||||
await expect(composer.getByTestId("message-composer")).toHaveAttribute(
|
||||
"data-submit-locked",
|
||||
"true",
|
||||
);
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
await expect(composer.getByTestId("message-composer")).toHaveAttribute(
|
||||
"data-submit-locked",
|
||||
"false",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("message-row").filter({ hasText: "queued review" }).last(),
|
||||
).toBeVisible();
|
||||
await expect(input).toHaveText("@Vogue ");
|
||||
await focusComposerEnd(input);
|
||||
await input.pressSequentially("follow-up after upload");
|
||||
await expect(input).toHaveText("@Vogue follow-up after upload");
|
||||
await input.press("Enter");
|
||||
await expect
|
||||
.poll(() =>
|
||||
readOutgoingMentionPubkeys(page, "@Vogue follow-up after upload"),
|
||||
)
|
||||
.toEqual([AGENT_B]);
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keyboard users can inspect and remove an overflow recipient", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agentC = "c".repeat(64);
|
||||
const agentD = "d".repeat(64);
|
||||
await keepMentionedAgentsPinned(page);
|
||||
await installAudienceFixtures(page, {
|
||||
additionalAgents: [
|
||||
{ pubkey: agentC, name: "Orchid" },
|
||||
{ pubkey: agentD, name: "Zinnia" },
|
||||
],
|
||||
});
|
||||
await openGeneral(page);
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
for (const name of ["Morgarita", "Vogue", "Orchid", "Zinnia"]) {
|
||||
await addMention(composer, name, "typed");
|
||||
}
|
||||
await expect(input).toHaveText("@Morgarita @Vogue @Orchid @Zinnia ");
|
||||
await expect(
|
||||
composer.getByTestId("composer-address-lock-avatar"),
|
||||
).toHaveCount(3);
|
||||
const overflow = composer.getByRole("button", {
|
||||
name: "Show 1 more mentioned agents",
|
||||
exact: true,
|
||||
});
|
||||
await expect(overflow).toHaveText("+1");
|
||||
await composer.getByRole("button", { name: "Mention someone" }).focus();
|
||||
for (let index = 0; index < 4; index += 1) await page.keyboard.press("Tab");
|
||||
await expect(overflow).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
const removeHidden = page.getByRole("button", {
|
||||
name: "Remove Zinnia from this and future messages",
|
||||
exact: true,
|
||||
});
|
||||
await expect(removeHidden).toBeVisible();
|
||||
await expect(removeHidden).toContainText("Zinnia");
|
||||
await expect(removeHidden).toBeFocused();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(removeHidden).toHaveCount(0);
|
||||
await expect(overflow).toHaveCount(0);
|
||||
await expect(input).toHaveText("@Morgarita @Vogue @Orchid ");
|
||||
for (const pubkey of [AGENT_A, AGENT_B, agentC]) {
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${pubkey}`),
|
||||
).toBeVisible();
|
||||
}
|
||||
await focusComposerEnd(input);
|
||||
await input.pressSequentially("overflow review");
|
||||
await input.press("Enter");
|
||||
await expect
|
||||
.poll(() =>
|
||||
readOutgoingMentionPubkeys(
|
||||
page,
|
||||
"@Morgarita @Vogue @Orchid overflow review",
|
||||
),
|
||||
)
|
||||
.toEqual([AGENT_A, AGENT_B, agentC]);
|
||||
await expect(composer.getByTestId("message-composer")).toHaveAttribute(
|
||||
"data-submit-locked",
|
||||
"false",
|
||||
);
|
||||
await expect(input).toHaveText("@Morgarita @Vogue @Orchid ");
|
||||
await expect(overflow).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("automatically mentions multiple agents from the mention picker", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -284,7 +334,7 @@ test("automatically mentions multiple agents from the mention picker", async ({
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_B}`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
composer.getByRole("button", { name: "Manage automatic agent mentions" }),
|
||||
composer.getByRole("button", { name: "Mention someone" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -401,7 +451,7 @@ test("Tab inserts a one-time agent mention by default", async ({ page }) => {
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
).toBeVisible();
|
||||
const selectAllShortcut = await page.evaluate(() =>
|
||||
/mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A",
|
||||
);
|
||||
@@ -469,9 +519,9 @@ test("primary+Shift+M addresses the default agent, then toggles the highlighted
|
||||
await pressPrimaryShiftM(page);
|
||||
await expect(input).toHaveText("@alice draft text");
|
||||
await expect(
|
||||
composer
|
||||
.getByTestId("composer-address-locks")
|
||||
.getByRole("button", { name: /^Don't automatically mention / }),
|
||||
composer.getByTestId("composer-address-locks").getByRole("button", {
|
||||
name: /^Remove .+ from this and future messages$/,
|
||||
}),
|
||||
).toHaveCount(1);
|
||||
|
||||
await input.fill("@Vog");
|
||||
@@ -490,9 +540,9 @@ test("primary+Shift+M addresses the default agent, then toggles the highlighted
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
composer
|
||||
.getByTestId("composer-address-locks")
|
||||
.getByRole("button", { name: /^Don't automatically mention / }),
|
||||
composer.getByTestId("composer-address-locks").getByRole("button", {
|
||||
name: /^Remove .+ from this and future messages$/,
|
||||
}),
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -521,6 +571,7 @@ test("primary+Shift+M favors the most recently mentioned eligible agent", async
|
||||
test("the mention button opens settings and can undo an address", async ({
|
||||
page,
|
||||
}) => {
|
||||
await keepMentionedAgentsPinned(page);
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
|
||||
@@ -528,7 +579,7 @@ test("the mention button opens settings and can undo an address", async ({
|
||||
await automaticallyMention(composer, "Morgarita");
|
||||
const input = composer.getByTestId("message-input");
|
||||
const ingress = composer.getByRole("button", {
|
||||
name: "Manage automatic agent mentions",
|
||||
name: "Mention someone",
|
||||
});
|
||||
|
||||
await input.type("draft text");
|
||||
@@ -537,9 +588,8 @@ test("the mention button opens settings and can undo an address", async ({
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(input).toHaveText("@Morgarita draft text");
|
||||
await page.getByTestId("mention-options-trigger").click();
|
||||
await expect(
|
||||
page.getByTestId("mention-keep-agents-pinned-toggle"),
|
||||
).toBeVisible();
|
||||
const preference = page.getByTestId("mention-keep-agents-pinned-toggle");
|
||||
await expect(preference).toHaveAttribute("data-state", "checked");
|
||||
const layerBox = await composer
|
||||
.getByTestId("mention-autocomplete-layer")
|
||||
.boundingBox();
|
||||
@@ -579,6 +629,10 @@ test("the mention button opens settings and can undo an address", async ({
|
||||
})
|
||||
.click();
|
||||
await expect(input).toHaveText("draft text");
|
||||
await page.getByTestId("mention-options-trigger").click();
|
||||
await expect(preference).toHaveAttribute("data-state", "checked");
|
||||
await preference.click();
|
||||
await expect(preference).toHaveAttribute("data-state", "unchecked");
|
||||
await expect(
|
||||
composer.getByRole("button", { name: "Mention someone" }),
|
||||
).toBeVisible();
|
||||
@@ -594,13 +648,19 @@ test("the mention button opens settings and can undo an address", async ({
|
||||
|
||||
await input.type("later");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveText("");
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@Morgarita later"))
|
||||
.toContain(AGENT_A);
|
||||
.toEqual([AGENT_A]);
|
||||
await expect(input).toHaveText("");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
(key) => localStorage.getItem(key) === "true",
|
||||
KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("always-mentioned agents remain selected without replaying their animation while Enter-send resolves", async ({
|
||||
@@ -656,7 +716,7 @@ test("always-mentioned agents remain selected without replaying their animation
|
||||
{ timeout: 500 },
|
||||
);
|
||||
await expect(
|
||||
composer.getByRole("button", { name: "Manage automatic agent mentions" }),
|
||||
composer.getByRole("button", { name: "Mention someone" }),
|
||||
).toBeVisible();
|
||||
await expect(input).toBeFocused();
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
|
||||
@@ -835,9 +895,10 @@ test("the auto-pin popover can turn off automatic agent mentions", async ({
|
||||
await expect(
|
||||
composer.getByTestId("mention-keep-agents-pinned-toggle"),
|
||||
).toHaveAttribute("data-state", "unchecked");
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
).toBeVisible();
|
||||
await expect(autoPinConfirmation).toHaveCount(0);
|
||||
|
||||
await input.press("Escape");
|
||||
@@ -929,7 +990,7 @@ test("automatic mentions are scoped to their channel or thread composer", async
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a thread automatic mention preserves an explicitly unpinned root agent", async ({
|
||||
test("a thread does not restore a removed root agent; explicit re-add follows automation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await keepMentionedAgentsPinned(page);
|
||||
@@ -950,16 +1011,6 @@ test("a thread automatic mention preserves an explicitly unpinned root agent", a
|
||||
rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
|
||||
await rootInput.fill("@cla");
|
||||
await expect(rootComposer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await rootInput.press("Tab");
|
||||
await rootInput.type("one time");
|
||||
await rootInput.press("Enter");
|
||||
await expect(rootInput).toHaveText("");
|
||||
await expect(
|
||||
rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
|
||||
await openThread(page);
|
||||
const activeThreadComposer = threadComposer(page);
|
||||
const threadInput = activeThreadComposer.getByTestId("message-input");
|
||||
@@ -987,39 +1038,47 @@ test("a thread automatic mention preserves an explicitly unpinned root agent", a
|
||||
await restoredRootInput.press("Tab");
|
||||
await expect(
|
||||
channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
await restoredRootInput.type("one time");
|
||||
).toBeVisible();
|
||||
await restoredRootInput.type("explicit re-add");
|
||||
await restoredRootInput.press("Enter");
|
||||
|
||||
await expect(restoredRootInput).toHaveText("");
|
||||
await expect
|
||||
.poll(() =>
|
||||
readOutgoingMentionPubkeys(page, "@claude code explicit re-add"),
|
||||
)
|
||||
.toEqual([AGENT_A]);
|
||||
await expect(restoredRootInput).toHaveText("@claude code ");
|
||||
await expect(
|
||||
channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("an unchecked agent remains excluded while automatic mentions stay enabled", async ({
|
||||
page,
|
||||
}) => {
|
||||
await keepMentionedAgentsPinned(page);
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
await automaticallyMention(channelComposer(page), "Morgarita");
|
||||
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click();
|
||||
await expect(input).toHaveText("");
|
||||
await input.fill("@Mor");
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await input.press("Tab");
|
||||
await input.type("one time");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toHaveText("");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
for (const mode of ["toolbar", "typed"] as const) {
|
||||
test(`explicit ${mode} re-add after X follows the enabled automatic setting`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await keepMentionedAgentsPinned(page);
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await automaticallyMention(composer, "Morgarita");
|
||||
await composer
|
||||
.getByTestId(`composer-address-lock-remove-${AGENT_A}`)
|
||||
.click();
|
||||
await expect(input).toHaveText("");
|
||||
await addMention(composer, "Morgarita", mode);
|
||||
await input.pressSequentially("re-added");
|
||||
await input.press("Enter");
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@Morgarita re-added"))
|
||||
.toEqual([AGENT_A]);
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test("re-adding a deleted automatic mention restores its automatic mention state immediately", async ({
|
||||
page,
|
||||
@@ -1084,19 +1143,7 @@ test("implicit automatic mentions stay out of persisted drafts", async ({
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((channelId) => {
|
||||
for (const storageKey of Object.keys(window.localStorage)) {
|
||||
if (!storageKey.startsWith("buzz-drafts.v2:")) continue;
|
||||
const drafts = JSON.parse(
|
||||
window.localStorage.getItem(storageKey) ?? "{}",
|
||||
) as Record<string, { channelId?: string; content?: string }>;
|
||||
const draft = drafts[channelId];
|
||||
if (draft?.channelId === channelId) return draft.content ?? "";
|
||||
}
|
||||
return "";
|
||||
}, CHANNEL_ID),
|
||||
)
|
||||
.poll(() => readPersistedDraft(page))
|
||||
.toBe("draft text continues");
|
||||
});
|
||||
|
||||
@@ -1122,19 +1169,7 @@ test("an authored duplicate leading mention survives draft restoration", async (
|
||||
});
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((channelId) => {
|
||||
for (const storageKey of Object.keys(window.localStorage)) {
|
||||
if (!storageKey.startsWith("buzz-drafts.v2:")) continue;
|
||||
const drafts = JSON.parse(
|
||||
window.localStorage.getItem(storageKey) ?? "{}",
|
||||
) as Record<string, { channelId?: string; content?: string }>;
|
||||
const draft = drafts[channelId];
|
||||
if (draft?.channelId === channelId) return draft.content ?? "";
|
||||
}
|
||||
return "";
|
||||
}, CHANNEL_ID),
|
||||
)
|
||||
.poll(() => readPersistedDraft(page))
|
||||
.toBe("@Morgarita authored duplicate");
|
||||
});
|
||||
|
||||
@@ -1162,20 +1197,7 @@ test("typed deletion preserves an identical authored mention in drafts", async (
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((channelId) => {
|
||||
for (const storageKey of Object.keys(window.localStorage)) {
|
||||
if (!storageKey.startsWith("buzz-drafts.v2:")) continue;
|
||||
const draft = (
|
||||
JSON.parse(
|
||||
window.localStorage.getItem(storageKey) ?? "{}",
|
||||
) as Record<string, { content?: string }>
|
||||
)[channelId];
|
||||
if (draft) return draft.content ?? "";
|
||||
}
|
||||
return "";
|
||||
}, CHANNEL_ID),
|
||||
)
|
||||
.poll(() => readPersistedDraft(page))
|
||||
.toBe("@Morgarita manual after typed deletion");
|
||||
});
|
||||
|
||||
@@ -1195,20 +1217,7 @@ test("removing an automatic mention preserves an identical authored mention in d
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((channelId) => {
|
||||
for (const storageKey of Object.keys(window.localStorage)) {
|
||||
if (!storageKey.startsWith("buzz-drafts.v2:")) continue;
|
||||
const draft = (
|
||||
JSON.parse(
|
||||
window.localStorage.getItem(storageKey) ?? "{}",
|
||||
) as Record<string, { content?: string }>
|
||||
)[channelId];
|
||||
if (draft) return draft.content ?? "";
|
||||
}
|
||||
return "";
|
||||
}, CHANNEL_ID),
|
||||
)
|
||||
.poll(() => readPersistedDraft(page))
|
||||
.toBe("@Morgarita manual after removal");
|
||||
});
|
||||
|
||||
@@ -1230,22 +1239,7 @@ test("multiple automatic mentions stay out of persisted drafts", async ({
|
||||
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((channelId) => {
|
||||
for (const storageKey of Object.keys(window.localStorage)) {
|
||||
if (!storageKey.startsWith("buzz-drafts.v2:")) continue;
|
||||
const draft = (
|
||||
JSON.parse(
|
||||
window.localStorage.getItem(storageKey) ?? "{}",
|
||||
) as Record<string, { content?: string }>
|
||||
)[channelId];
|
||||
if (draft) return draft.content ?? "";
|
||||
}
|
||||
return "";
|
||||
}, CHANNEL_ID),
|
||||
)
|
||||
.toBe("draft text");
|
||||
await expect.poll(() => readPersistedDraft(page)).toBe("draft text");
|
||||
});
|
||||
|
||||
test("re-enabling an automatic mention preserves an authored duplicate after draft restoration", async ({
|
||||
@@ -1273,19 +1267,7 @@ test("re-enabling an automatic mention preserves an authored duplicate after dra
|
||||
});
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((channelId) => {
|
||||
for (const storageKey of Object.keys(window.localStorage)) {
|
||||
if (!storageKey.startsWith("buzz-drafts.v2:")) continue;
|
||||
const drafts = JSON.parse(
|
||||
window.localStorage.getItem(storageKey) ?? "{}",
|
||||
) as Record<string, { channelId?: string; content?: string }>;
|
||||
const draft = drafts[channelId];
|
||||
if (draft?.channelId === channelId) return draft.content ?? "";
|
||||
}
|
||||
return "";
|
||||
}, CHANNEL_ID),
|
||||
)
|
||||
.poll(() => readPersistedDraft(page))
|
||||
.toBe("@Morgarita authored duplicate");
|
||||
});
|
||||
|
||||
@@ -1319,7 +1301,7 @@ test("a restored multi-word automatic mention remains a chip with the caret afte
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
composer.getByRole("button", { name: "Manage automatic agent mentions" }),
|
||||
composer.getByRole("button", { name: "Mention someone" }),
|
||||
).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
|
||||
@@ -1390,7 +1372,7 @@ test("the mention-button placement fits the narrow composer", async ({
|
||||
await automaticallyMention(overlay, "Vogue");
|
||||
await expect(overlay.getByTestId("composer-address-locks")).toBeVisible();
|
||||
await expect(
|
||||
overlay.getByRole("button", { name: "Manage automatic agent mentions" }),
|
||||
overlay.getByRole("button", { name: "Mention someone" }),
|
||||
).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await composer.screenshot({ path: `${SHOTS}/narrow-mention-button.png` });
|
||||
|
||||
278
desktop/tests/helpers/persistentAgentAudience.ts
Normal file
278
desktop/tests/helpers/persistentAgentAudience.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import type { TiptapEditorHTMLElement } from "@tiptap/core";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "./bridge";
|
||||
|
||||
export const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
export const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
|
||||
export const AGENT_A = "a".repeat(64);
|
||||
export const AGENT_B = "b".repeat(64);
|
||||
export const THREAD_ROOT_ID = "mock-general-welcome";
|
||||
export const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY =
|
||||
"buzz.messages.keepMentionedAgentsPinned";
|
||||
|
||||
export async function keepMentionedAgentsPinned(page: Page) {
|
||||
await page.addInitScript((storageKey) => {
|
||||
window.localStorage.setItem(storageKey, "true");
|
||||
}, KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export async function seedTheme(page: Page, theme: string, accent = "#c0a2f1") {
|
||||
await page.addInitScript(
|
||||
({ selectedTheme, selectedAccent }) => {
|
||||
window.localStorage.setItem("buzz-theme", selectedTheme);
|
||||
window.localStorage.setItem("buzz-accent-color", selectedAccent);
|
||||
},
|
||||
{ selectedTheme: theme, selectedAccent: accent },
|
||||
);
|
||||
}
|
||||
|
||||
export async function automaticallyMention(
|
||||
composer: ReturnType<typeof channelComposer>,
|
||||
displayName: string,
|
||||
) {
|
||||
await composer.locator("[data-mention-picker-trigger]").click();
|
||||
await composer
|
||||
.getByTestId("mention-autocomplete")
|
||||
.getByRole("button", { name: `Automatically mention ${displayName}` })
|
||||
.click();
|
||||
await expect(composer.getByTestId("message-input")).toContainText(
|
||||
`@${displayName}`,
|
||||
);
|
||||
await composer.locator("[data-mention-picker-trigger]").click();
|
||||
}
|
||||
|
||||
export async function openGeneral(page: Page) {
|
||||
await page.goto(`/#/channels/${CHANNEL_ID}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
}
|
||||
|
||||
export 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();
|
||||
}
|
||||
|
||||
export function channelComposer(page: Page) {
|
||||
return page.getByTestId("channel-composer-overlay");
|
||||
}
|
||||
|
||||
export function threadComposer(page: Page) {
|
||||
return page.getByTestId("thread-composer-overlay");
|
||||
}
|
||||
|
||||
export async function readComposerCaret(input: Locator) {
|
||||
return input.evaluate((element) => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.anchorNode || !element.contains(selection.anchorNode)) {
|
||||
return null;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
range.setEnd(selection.anchorNode, selection.anchorOffset);
|
||||
return range.toString().length;
|
||||
});
|
||||
}
|
||||
|
||||
/** Assert selection without moving it, so autocomplete regressions stay visible. */
|
||||
export async function expectComposerCaretAtEnd(input: Locator) {
|
||||
const readSelection = () =>
|
||||
input.evaluate((element) => {
|
||||
const editor = (element as TiptapEditorHTMLElement).editor;
|
||||
const selection = window.getSelection();
|
||||
if (!editor || !selection?.anchorNode || !selection.focusNode)
|
||||
return null;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
range.setEnd(selection.anchorNode, selection.anchorOffset);
|
||||
return {
|
||||
focused: document.activeElement === element,
|
||||
collapsed: selection.isCollapsed && editor.state.selection.empty,
|
||||
atEnd: range.toString().length === element.textContent?.length,
|
||||
agreesWithEditor:
|
||||
editor.view.posAtDOM(selection.anchorNode, selection.anchorOffset) ===
|
||||
editor.state.selection.anchor &&
|
||||
editor.view.posAtDOM(selection.focusNode, selection.focusOffset) ===
|
||||
editor.state.selection.head,
|
||||
};
|
||||
});
|
||||
const expected = {
|
||||
focused: true,
|
||||
collapsed: true,
|
||||
atEnd: true,
|
||||
agreesWithEditor: true,
|
||||
};
|
||||
await expect.poll(readSelection).toEqual(expected);
|
||||
await input.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
expect(await readSelection()).toEqual(expected);
|
||||
}
|
||||
|
||||
/** Explicit append setup after an edit that intentionally moves the caret. */
|
||||
export async function focusComposerEnd(input: Locator) {
|
||||
await input.evaluate((element) => {
|
||||
const editor = (element as TiptapEditorHTMLElement).editor;
|
||||
if (!editor || editor.isDestroyed) throw new Error("Composer not mounted");
|
||||
editor.commands.focus("end", { scrollIntoView: false });
|
||||
});
|
||||
await expectComposerCaretAtEnd(input);
|
||||
}
|
||||
|
||||
export async function pressPrimaryShiftM(page: Page) {
|
||||
const isMac = await page.evaluate(() =>
|
||||
/mac|iphone|ipad|ipod/i.test(navigator.platform),
|
||||
);
|
||||
await page.keyboard.press(`${isMac ? "Meta" : "Control"}+Shift+M`);
|
||||
}
|
||||
|
||||
export async function readOutgoingMentionPubkeys(page: Page, content: string) {
|
||||
return page.evaluate((expectedContent) => {
|
||||
const signedEvent = window.__BUZZ_E2E_SIGNED_EVENTS__?.find(
|
||||
(event) => event.content === expectedContent,
|
||||
);
|
||||
if (signedEvent) {
|
||||
return (signedEvent.tags ?? [])
|
||||
.filter((tag) => tag[0] === "p" && tag[1])
|
||||
.map((tag) => tag[1]);
|
||||
}
|
||||
|
||||
for (const entry of window.__BUZZ_E2E_COMMAND_LOG__ ?? []) {
|
||||
if (entry.command === "send_channel_message") {
|
||||
const payload = entry.payload as
|
||||
| { content?: string; mentionPubkeys?: string[] }
|
||||
| undefined;
|
||||
if (payload?.content === expectedContent) {
|
||||
return payload.mentionPubkeys ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.command !== "plugin:websocket|send") continue;
|
||||
const data = (
|
||||
entry.payload as { message?: { data?: string } } | undefined
|
||||
)?.message?.data;
|
||||
if (!data) continue;
|
||||
|
||||
try {
|
||||
const frame = JSON.parse(data) as [
|
||||
string,
|
||||
{ content?: string; tags?: string[][] },
|
||||
];
|
||||
if (frame[0] !== "EVENT" || frame[1]?.content !== expectedContent) {
|
||||
continue;
|
||||
}
|
||||
return (frame[1].tags ?? [])
|
||||
.filter((tag) => tag[0] === "p" && tag[1])
|
||||
.map((tag) => tag[1]);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, content);
|
||||
}
|
||||
|
||||
export async function readPersistedDraft(page: Page) {
|
||||
return page.evaluate((channelId) => {
|
||||
for (const storageKey of Object.keys(window.localStorage)) {
|
||||
if (!storageKey.startsWith("buzz-drafts.v2:")) continue;
|
||||
const drafts = JSON.parse(
|
||||
window.localStorage.getItem(storageKey) ?? "{}",
|
||||
) as Record<string, { content?: string }>;
|
||||
if (drafts[channelId]) return drafts[channelId].content ?? "";
|
||||
}
|
||||
return "";
|
||||
}, CHANNEL_ID);
|
||||
}
|
||||
|
||||
export async function addMention(
|
||||
composer: Locator,
|
||||
name: string,
|
||||
mode: "toolbar" | "typed",
|
||||
) {
|
||||
const input = composer.getByTestId("message-input");
|
||||
if (mode === "toolbar") {
|
||||
await composer.getByTestId("message-insert-mention").click();
|
||||
await composer
|
||||
.getByRole("button", { name: `Mention ${name}`, exact: true })
|
||||
.click();
|
||||
} else {
|
||||
await input.pressSequentially(`@${name.slice(0, 3)}`);
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await input.press("Tab");
|
||||
}
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
|
||||
// Do not press End: on macOS it cancels chip-edge settlement without
|
||||
// moving the caret. Verify the natural autocomplete selection instead.
|
||||
await expectComposerCaretAtEnd(input);
|
||||
}
|
||||
|
||||
export async function emitMockMessage(
|
||||
page: Page,
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ body, mentions }) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: body,
|
||||
mentionPubkeys: mentions,
|
||||
});
|
||||
},
|
||||
{ body: content, mentions: mentionPubkeys },
|
||||
);
|
||||
}
|
||||
|
||||
export async function installAudienceFixtures(
|
||||
page: Page,
|
||||
options: {
|
||||
agentAName?: string;
|
||||
additionalAgents?: Array<{ pubkey: string; name: string }>;
|
||||
deferredComposerUploads?: boolean;
|
||||
sendMessageDelayMs?: number;
|
||||
sendMessageErrors?: string[];
|
||||
uploadDelayMs?: number;
|
||||
uploadDescriptors?: Array<{
|
||||
filename: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
type: string;
|
||||
uploaded: number;
|
||||
url: string;
|
||||
}>;
|
||||
usersBatchDelayMs?: number;
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
agentAName = "Morgarita",
|
||||
additionalAgents = [],
|
||||
...bridgeOptions
|
||||
} = options;
|
||||
await installMockBridge(page, {
|
||||
...bridgeOptions,
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: AGENT_A,
|
||||
name: agentAName,
|
||||
status: "running",
|
||||
channelNames: ["general"],
|
||||
},
|
||||
{
|
||||
pubkey: AGENT_B,
|
||||
name: "Vogue",
|
||||
status: "running",
|
||||
channelNames: ["general"],
|
||||
},
|
||||
...additionalAgents.map((agent) => ({
|
||||
...agent,
|
||||
status: "running" as const,
|
||||
channelNames: ["general"],
|
||||
})),
|
||||
],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user