Files
buzz/desktop/tests/e2e/persistent-agent-audience.spec.ts
Matt Toohey 15c2ca1b01 test(desktop): wait on real readiness signals in audience and community-rail specs (#9277)
## Summary
Split out of the `flakey-monday` flake-fix series. Three Desktop E2E
tests in `persistent-agent-audience.spec.ts` and
`community-rail.spec.ts` acted on signals that could arrive before the
app state they implied (a logged command before the submit lock
rendered, a URL check that only held before a view transition landed).
They now wait on the readiness signals the product actually exposes. No
product code changes.

Commits (cherry-picked from `flakey-monday`, original messages kept):
- `test(desktop): wait for composer focus and send cycles in audience
specs`
- `test(desktop): assert the kept remembered route after the community
switch`
- `test(desktop): assert Home after repairing the remembered channel`

### Root cause and fix per test

**`a manual mention persists when automatic mentions are enabled`**
(`desktop/tests/e2e/persistent-agent-audience.spec.ts`):
`locator.fill("follow up")` failed because `message-input` was not
editable.
- Root cause (from the code and CI symptom; not reproduced locally): the
test sends with a 1.5 s mock send delay, during which the composer is
submit-locked and `contenteditable="false"`. The lock is React state and
renders a frame after Enter. The test's last two checks before the fill
could both pass before that frame: the outgoing-mention poll (the e2e
bridge logs a command when invoked, before the delay) and the
`contenteditable="true"` wait. Playwright's `fill` rejects a
`contenteditable="false"` element outright instead of waiting.
- Fix: after Enter, wait for the full send cycle: `data-submit-locked`
"true", `contenteditable` "false", `data-submit-locked` "false",
`contenteditable` "true". Only then assert on the editor. Existing
assertions are unchanged.

**`the mention setting is reachable and operable by keyboard`** (same
spec): the thread composer's mention autocomplete never became visible.
- Root cause (likely; not reproduced locally): opening a thread by URL
mounts both the channel composer and the thread composer.
`useComposerAutofocus` focuses whichever mounts first and TipTap defers
`focus()` by a frame. A click and fill that land before that deferred
focus lose it, and the focus-gated mention menu unmounts.
- Fix: a `readyThreadComposerInput()` helper waits for the thread editor
to be editable and for exactly one `message-input` to hold focus, so the
app's own autofocus has landed. Both tests use it.

**`community rail > does not repair a remembered channel until live
validation succeeds`** (`desktop/tests/e2e/community-rail.spec.ts`):
`expect(page).not.toHaveURL(/#\/channels\/general$/)` failed right after
the click on community B.
- Root cause (confirmed; 21/40 failures at 14 workers on the old spec):
`switchCommunity` runs inside a view transition whose callback writes
B's remembered channel into the hash and then activates B, a few frames
after the click returns. By design the URL is then `#/channels/general`,
which `AppShell` keeps until a live channel read succeeds and only then
repairs to Home. The inverted check from 631b05c88 passed only when its
first poll beat the view transition.
- Fix: wait for B's rail button to have `aria-current="true"` (switch
finished), then assert the URL is `#/channels/general`, the kept route.
After live validation repairs storage to Home, assert the Home route
`/#\/$/` (the follow-up commit; `not.toHaveURL(general)` would have
passed for any route, and `AppShell`'s repair calls `goHome({ replace:
true })`).

### Related issue
N/A (flake fixes; none found).

### Testing
Human testing is still required. This PR does not carry
`buzz-review-completed`; the author has not yet run these specs
themselves on this branch.

#### Verification
Verified locally on the `flakey-monday` branch before the split
(Playwright smoke project, e2e build):
- Audience spec: neither flake reproduced before the fix, even with CPU
throttled 4x and 8x. After the fix, both tests 40 repeats each at 10
workers: 80/80; whole spec: 35/35.
- Community-rail test: 40 repeats at 14 workers: 40/40; whole spec:
27/27 at 4 workers. The Home assertion was falsified: with the repair
temporarily sending the app to `#/channels/random`, the new assertion
failed and the previous `not.toHaveURL` check passed.
- The four desktop flake tests from the series together, 10 repeats each
at 8 workers: 40/40.
- Two untouched tests in these specs are pre-existing flakes a baseline
build fails as often, and are out of scope: community-rail "enters a
remembered channel before live validation completes" and
persistent-agent-audience "the mention button preserves manual
rementions after undo until explicitly readdressed".

Nothing was re-run on this split branch: the workstation was overloaded,
so no tests, builds or linters were executed after cherry-picking. CI on
this PR is the verification for the split branch.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: mtoohey <mtoohey@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:38:40 +11:00

1637 lines
56 KiB
TypeScript

import { expect, test, type Locator, type Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
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) => {
window.localStorage.removeItem(storageKey);
}, 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 waitForMockLiveSubscription(page: Page, channelName: string) {
await expect
.poll(() =>
page.evaluate(
(currentChannelName) =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: currentChannelName,
}) ?? false,
channelName,
),
)
.toBe(true);
}
async function waitForTimelineSettled(page: Page) {
await expect(page.locator("[data-render-pending]")).toHaveCount(0);
}
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");
}
/**
* Returns the thread composer's editor once the app has finished placing
* focus on its own. Opening a thread by URL mounts the channel and thread
* composers, and whichever mounts first autofocuses itself a frame later
* (TipTap's `focus()` is deferred). A click that lands before that frame is
* undone by the late autofocus, which blurs the editor the test just typed in
* and unmounts its focus-gated mention menu.
*/
async function readyThreadComposerInput(page: Page) {
const input = threadComposer(page).getByTestId("message-input");
await expect(input).toHaveAttribute("contenteditable", "true");
await expect(page.locator('[data-testid="message-input"]:focus')).toHaveCount(
1,
);
return input;
}
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 readPersistedDraftContent(page: Page, draftKey: string) {
return page.evaluate((key) => {
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 }>;
const draft = drafts[key];
if (draft) return draft.content ?? "";
}
return "";
}, draftKey);
}
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,
}) => {
await installAudienceFixtures(page, {
deferredComposerUploads: true,
uploadDelayMs: 2_000,
sendMessageDelayMs: 2_000,
uploadDescriptors: [
{
filename: "delayed-video.mp4",
sha256: "d".repeat(64),
size: 16,
type: "video/mp4",
uploaded: 1,
url: `https://mock.relay/media/${"d".repeat(64)}.mp4`,
},
],
});
await openGeneral(page);
const composer = channelComposer(page);
const composerForm = composer.getByTestId("message-composer");
const input = composer.getByTestId("message-input");
await input.fill("delayed upload");
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
composer.getByRole("button", { name: "Attach file" }).click(),
]);
await chooser.setFiles({
buffer: Buffer.from("delayed video"),
mimeType: "video/mp4",
name: "delayed-video.mp4",
});
const sendAttempts = () =>
page.evaluate(
() =>
window.__BUZZ_E2E_COMMAND_LOG__?.filter(
(entry) => entry.command === "send_channel_message",
).length ?? 0,
);
const sendAttemptsBefore = await sendAttempts();
await input.press("Enter");
await expect(composer.getByTestId("composer-upload-progress")).toBeVisible();
// Observe the synchronous lock itself after upload has started. The exact
// premature-release mutation (`void uploadSettled; await Promise.resolve()`)
// has already cleared this attribute by this boundary, before any retryable
// downstream command or restored-audience timing can obscure the defect.
await expect(composerForm).toHaveAttribute("data-submit-locked", "true");
await input.press("Enter");
await input.press("Enter");
await expect(composerForm).toHaveAttribute("data-submit-locked", "true");
await expect.poll(sendAttempts).toBe(sendAttemptsBefore + 1);
// Command entry marks the independently delayed finishSend() window. Keep
// the synchronous lock held and fence repeated submits until it settles.
await expect(composer.getByTestId("composer-upload-progress")).toBeVisible();
await expect(composerForm).toHaveAttribute("data-submit-locked", "true");
await input.press("Enter");
await input.press("Enter");
await expect(composerForm).toHaveAttribute("data-submit-locked", "true");
await expect(
page
.getByTestId("message-row")
.filter({ hasText: "delayed upload" })
.last(),
).toBeVisible({ timeout: 5_000 });
await expect(composerForm).toHaveAttribute("data-submit-locked", "false");
});
test("automatically mentions multiple agents from the mention picker", async ({
page,
}) => {
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
await automaticallyMention(composer, "Morgarita");
await automaticallyMention(composer, "Vogue");
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toBeVisible();
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_B}`),
).toBeVisible();
await expect(
composer.getByRole("button", { name: "Manage mentions" }),
).toBeVisible();
});
test("keeps the composer and global automatic mention settings synchronized", async ({
page,
}) => {
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
await composer.getByTestId("message-insert-mention").click();
const composerToggle = composer.getByTestId(
"mention-keep-agents-pinned-toggle",
);
await expect(composerToggle).toHaveAttribute("data-state", "unchecked");
const settings = composer.getByTestId("mention-options-settings");
await expect(settings).toBeVisible();
const settingsBox = await settings.boundingBox();
const heading = settings.getByText("Automatically mention agents");
const headingBox = await heading.boundingBox();
expect(settingsBox?.width).toBeGreaterThanOrEqual(300);
expect(headingBox?.height).toBeLessThanOrEqual(20);
await composer
.getByTestId("mention-autocomplete")
.getByRole("button", { name: "Automatically mention Morgarita" })
.click();
await expect(composerToggle).toHaveAttribute("data-state", "checked", {
timeout: 1_500,
});
await page
.getByTestId("composer-auto-pin-confirmation")
.getByRole("button", { name: "Turn off" })
.click();
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
await expect(composer.getByTestId("mention-options-settings")).toBeVisible();
await expect(composerToggle).toHaveAttribute("data-state", "unchecked", {
timeout: 1_500,
});
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
await page.getByTestId("settings-nav-agents").click();
const settingsToggle = page
.getByTestId("settings-automatic-agent-mentions")
.getByRole("switch", { name: "Automatically mention agents" });
await expect(settingsToggle).toHaveAttribute("data-state", "unchecked");
await page.getByTestId("settings-back-to-app").click();
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toHaveCount(0);
await composer.getByTestId("message-insert-mention").click();
await expect(
composer.getByTestId("mention-keep-agents-pinned-toggle"),
).toHaveAttribute("data-state", "unchecked");
await expect(
composer.getByRole("button", {
name: "Automatically mention Morgarita",
}),
).toHaveAttribute("aria-pressed", "false");
});
test("the disabled root composer preserves its explicit draft without audience controls", async ({
page,
}) => {
await installAudienceFixtures(page);
await openGeneral(page);
const composer = channelComposer(page);
const input = composer.getByTestId("message-input");
await input.fill("explicit draft text");
await expect(composer.getByTestId("composer-address-locks")).toHaveCount(0);
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await page.getByTestId("channel-management-archive").click();
await expect(page.getByTestId("channel-management-unarchive")).toBeVisible();
await page.getByTestId("auxiliary-panel-close").click();
await expect(input).toHaveAttribute("contenteditable", "false");
await expect(input).toHaveText("explicit draft text");
await expect(composer.getByTestId("composer-address-locks")).toHaveCount(0);
});
test("Tab inserts a one-time agent mention by default", async ({ page }) => {
await installAudienceFixtures(page);
await openGeneral(page);
const composer = channelComposer(page);
const input = composer.getByTestId("message-input");
await input.fill("@Mor");
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
await input.press("Tab");
await expect(input).toHaveText("@Morgarita ");
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toHaveCount(0);
const selectAllShortcut = await page.evaluate(() =>
/mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A",
);
await input.press(selectAllShortcut);
await input.press("Backspace");
await expect(input).toHaveText("");
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toHaveCount(0);
});
test("disabling automatic mentions leaves the composer empty after send", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
await composer.getByTestId("message-insert-mention").click();
const preference = composer.getByTestId("mention-keep-agents-pinned-toggle");
await expect(preference).toHaveAttribute("data-state", "checked");
await preference.click();
await expect(preference).toHaveAttribute("data-state", "unchecked");
await input.press("Escape");
await input.fill("@Mor");
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
await input.press("Tab");
await input.type("test");
await expect(input).toHaveText("@Morgarita test");
await input.press("Enter");
await expect(input).toHaveText("");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(0);
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toHaveCount(0);
await expect
.poll(() => readOutgoingMentionPubkeys(page, "@Morgarita test"))
.toContain(AGENT_A);
});
test("primary+Shift+M addresses the default agent, then toggles the highlighted agent in place", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
await input.fill("draft text");
await pressPrimaryShiftM(page);
await expect(input).toHaveText("@alice draft text");
await expect(input.locator(".agent-mention-highlight")).toHaveText("alice");
await expect(
page.getByTestId("composer-auto-pin-confirmation"),
).toContainText("alice will be mentioned automatically");
await pressPrimaryShiftM(page);
await expect(input).toHaveText("draft text");
await pressPrimaryShiftM(page);
await expect(input).toHaveText("@alice draft text");
await expect(
composer
.getByTestId("composer-address-locks")
.getByRole("button", { name: /^Don't automatically mention / }),
).toHaveCount(1);
await input.fill("@Vog");
const menu = composer.getByTestId("mention-autocomplete");
await expect(menu.getByTestId(`mention-suggestion-${AGENT_B}`)).toHaveClass(
/(?:^|\s)bg-accent(?:\s|$)/,
);
await pressPrimaryShiftM(page);
await expect(menu).toBeVisible();
await expect(input).toHaveText("@Vogue ");
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_B}`),
).toBeVisible();
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toHaveCount(0);
await expect(
composer
.getByTestId("composer-address-locks")
.getByRole("button", { name: /^Don't automatically mention / }),
).toHaveCount(1);
});
test("primary+Shift+M favors the most recently mentioned eligible agent", async ({
page,
}) => {
await installAudienceFixtures(page);
await openThread(page);
await emitMockMessage(page, "Please ask Vogue", [AGENT_B]);
const input = threadComposer(page).getByTestId("message-input");
await input.fill("draft text");
await input.press("ArrowLeft");
await input.press("ArrowLeft");
await expect.poll(() => readComposerCaret(input)).toBe(8);
await pressPrimaryShiftM(page);
await expect(input).toHaveText("@Vogue draft text");
await expect.poll(() => readComposerCaret(input)).toBe(15);
await pressPrimaryShiftM(page);
await expect(input).toHaveText("draft text");
await pressPrimaryShiftM(page);
await expect(input).toHaveText("@Vogue draft text");
});
test("the mention button preserves manual rementions after undo until explicitly readdressed", async ({
page,
}) => {
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
await automaticallyMention(composer, "Morgarita");
const input = composer.getByTestId("message-input");
const ingress = composer.getByRole("button", {
name: "Manage mentions",
});
await input.type("draft text");
await ingress.click();
const menu = composer.getByTestId("mention-autocomplete");
await expect(menu).toBeVisible();
await expect(input).toHaveText("@Morgarita draft text");
await expect(
page.getByTestId("mention-keep-agents-pinned-toggle"),
).toBeVisible();
const layerBox = await composer
.getByTestId("mention-autocomplete-layer")
.boundingBox();
const settingsBox = await page
.getByTestId("mention-options-settings")
.boundingBox();
const menuBox = await menu.boundingBox();
expect(layerBox).not.toBeNull();
expect(settingsBox).not.toBeNull();
expect(menuBox).not.toBeNull();
if (!layerBox || !settingsBox || !menuBox) {
throw new Error("Mention tray is not laid out");
}
await page.mouse.click(
layerBox.x + layerBox.width / 2,
(settingsBox.y + settingsBox.height + menuBox.y) / 2,
);
await expect(menu).toHaveCount(0);
await expect(input).toHaveText("@Morgarita draft text");
await ingress.click();
await expect(menu).toBeVisible();
await expect(page.getByTestId("mention-options-settings")).toBeVisible();
await expect(
page.getByTestId("mention-keep-agents-pinned-toggle"),
).toBeVisible();
await ingress.click();
await expect(menu).toHaveCount(0);
await expect(input).toHaveText("@Morgarita draft text");
await ingress.click();
await expect(menu).toBeVisible();
await expect(page.getByTestId("user-profile-panel")).toHaveCount(0);
await expect(
menu.getByRole("button", {
name: "Don't automatically mention Morgarita in this thread",
}),
).toHaveAttribute("aria-pressed", "true");
await menu
.getByRole("button", {
name: "Don't automatically mention Morgarita in this thread",
})
.click();
await expect(input).toHaveText("@Morgarita draft text");
await expect(
composer.getByRole("button", { name: "Mention someone" }),
).toBeVisible();
const avatar = composer.getByTestId(`composer-address-lock-${AGENT_A}`);
// Observe the old address exit before testing the manual selection.
await expect(avatar).toHaveCount(0);
// Clear through editor transactions before selecting a manual mention.
await input.press("ControlOrMeta+A");
await input.press("Backspace");
await expect(input).toHaveText("");
await menu
.getByRole("button", { name: "Mention Morgarita", exact: true })
.click();
await expect(input).toHaveText("@Morgarita ");
// Explicitly opting out keeps subsequent mentions manual. The old avatar's
// exit animation must not be mistaken for a newly pinned recipient.
await expect(avatar).toHaveCount(0);
await input.type("later");
await input.press("Enter");
await expect(input).toHaveText("");
await expect
.poll(() => readOutgoingMentionPubkeys(page, "@Morgarita later"))
.toEqual([AGENT_A]);
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toHaveCount(0);
// Only the explicit automatic action reinstates the excluded address.
await automaticallyMention(composer, "Morgarita");
await expect(avatar).toBeVisible();
await input.type("explicit recovery");
await input.press("Enter");
await expect
.poll(() =>
readOutgoingMentionPubkeys(page, "@Morgarita explicit recovery"),
)
.toEqual([AGENT_A]);
await expect(input).toHaveText("@Morgarita ");
await expect(avatar).toBeVisible();
});
test("always-mentioned agents remain selected without replaying their animation while Enter-send resolves", async ({
page,
}) => {
await installAudienceFixtures(page, {
sendMessageDelayMs: 1_500,
usersBatchDelayMs: 1_500,
});
await openThread(page);
const composer = threadComposer(page);
await automaticallyMention(composer, "Morgarita");
const input = composer.getByTestId("message-input");
await input.evaluate((element) => {
const snapshots = [element.textContent ?? ""];
new MutationObserver(() =>
snapshots.push(element.textContent ?? ""),
).observe(element, { childList: true, characterData: true, subtree: true });
(
window as typeof window & { __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[] }
).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__ = snapshots;
});
const avatar = composer.getByTestId(`composer-address-lock-${AGENT_A}`);
const initialPulseVersion = Number(
await avatar.getAttribute("data-pulse-version"),
);
await input.type("hello");
await input.evaluate((element) => {
const snapshots = (
window as typeof window & { __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[] }
).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__;
snapshots?.splice(0, snapshots.length, element.textContent ?? "");
});
await input.press("Enter");
await expect(input).toHaveText("@Morgarita ", { timeout: 500 });
await expect
.poll(() =>
page.evaluate(
() =>
(
window as typeof window & {
__BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[];
}
).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__ ?? [],
),
)
.not.toContain("");
await expect(avatar).toHaveAttribute(
"data-pulse-version",
String(initialPulseVersion),
{ timeout: 500 },
);
await expect(
composer.getByRole("button", { name: "Manage mentions" }),
).toBeVisible();
await expect(input).toBeFocused();
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
await expect
.poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello"))
.toContain(AGENT_A);
await expect(input).toHaveText("@Morgarita ");
const sentRow = page
.getByTestId("message-row")
.filter({ hasText: "hello" })
.last();
const addressPrefix = sentRow.locator(
"[data-mention].agent-mention-highlight",
{ hasText: "Morgarita" },
);
await expect(addressPrefix).toBeVisible();
await expect(addressPrefix).toHaveText("Morgarita");
await input.type("follow up");
await input.press("Enter");
const inlineMentionRow = page
.getByTestId("message-row")
.filter({ hasText: "follow up" })
.last();
await expect(
inlineMentionRow.locator("[data-mention].agent-mention-highlight", {
hasText: "Morgarita",
}),
).toHaveCount(1);
});
test("the unfocused root composer keeps its dismissed mention menu closed through a thread send", async ({
page,
}) => {
await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 });
await openGeneral(page);
const mainComposer = channelComposer(page);
const mainInput = mainComposer.getByTestId("message-input");
await mainInput.fill("@Mor");
await expect(mainComposer.getByTestId("mention-autocomplete")).toBeVisible();
await mainInput.press("Escape");
await expect(mainComposer.getByTestId("mention-autocomplete")).toHaveCount(0);
const rootMessage = page.locator(
`[data-testid="message-row"][data-message-id="${THREAD_ROOT_ID}"]`,
);
await rootMessage
.getByRole("button", { name: "Reply" })
.evaluate((button: HTMLButtonElement) => button.click());
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
const threadInput = threadPanel.getByTestId("message-input");
await threadInput.click();
await expect(threadInput).toBeFocused();
await expect(mainComposer.getByTestId("mention-autocomplete")).toHaveCount(0);
await mainComposer.evaluate((element) => {
element.dataset.mentionMenuReopened = "false";
new MutationObserver(() => {
if (element.querySelector('[data-testid="mention-autocomplete"]')) {
element.dataset.mentionMenuReopened = "true";
}
}).observe(element, { childList: true, subtree: true });
});
const reply = `Thread reply ${Date.now()}`;
await threadInput.fill(reply);
await threadInput.press("Enter");
await expect(mainInput).toHaveAttribute("contenteditable", "false");
await expect(threadPanel).toContainText(reply);
await expect(mainInput).toHaveAttribute("contenteditable", "true");
expect(await mainComposer.getAttribute("data-mention-menu-reopened")).toBe(
"false",
);
await expect(mainComposer.getByTestId("mention-autocomplete")).toHaveCount(0);
// Refocusing the drafted composer must not resurrect the menu the user
// dismissed with Escape: the setEditable toggles around the send used to
// emit a phantom update that replayed the stale "@Morgarita" query and
// flipped the mention state back open, so a bare click brought the menu
// back without any typing.
await mainInput.click();
await expect(mainInput).toBeFocused();
await expect(mainComposer.getByTestId("mention-autocomplete")).toHaveCount(0);
expect(await mainComposer.getAttribute("data-mention-menu-reopened")).toBe(
"false",
);
});
test("pressing a mention overlay's own container keeps it open", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
await composer.getByTestId("message-insert-mention").click();
const list = composer.getByTestId("mention-autocomplete");
await expect(list).toBeVisible();
await expect(input).toBeFocused();
// A mousedown landing on the list container itself — its padding ring here,
// a native scrollbar on platforms that render one — steals focus from the
// editor unless the default is prevented, and the focus gate would then
// unmount the menu mid-press.
const listBox = await list.boundingBox();
if (!listBox) throw new Error("mention list is not laid out");
await list.click({ position: { x: 2, y: listBox.height / 2 } });
await expect(input).toBeFocused();
await expect(list).toBeVisible();
// Same hazard on the options surface, where it needs no exotic scrollbar
// setting to reproduce: the switch's label text is a container press, so the
// overlay used to vanish before the forwarded click reached the switch.
const preference = composer.getByTestId("mention-keep-agents-pinned-toggle");
await expect(preference).toHaveAttribute("data-state", "checked");
await composer
.getByText("Automatically mention agents", { exact: true })
.click();
await expect(preference).toHaveAttribute("data-state", "unchecked");
await expect(input).toBeFocused();
await expect(list).toBeVisible();
});
test("the mention setting is reachable and operable by keyboard", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openThread(page);
const mainComposer = threadComposer(page);
const mainInput = await readyThreadComposerInput(page);
await mainInput.click();
await mainInput.fill("@Mor");
const list = mainComposer.getByTestId("mention-autocomplete");
await expect(list).toBeVisible();
await expect(mainInput).toBeFocused();
// Trip a flag if the overlay ever unmounts from here on — "operable while
// the surface stays mounted" has to hold through every focus handoff below,
// not just at the polled assertion boundaries.
await mainComposer.evaluate((element) => {
element.dataset.mentionMenuUnmounted = "false";
new MutationObserver(() => {
if (!element.querySelector('[data-testid="mention-autocomplete"]')) {
element.dataset.mentionMenuUnmounted = "true";
}
}).observe(element, { childList: true, subtree: true });
});
// Forward Tab still selects the highlighted suggestion, so Shift+Tab is the
// route into the always-visible setting. The focus gate must treat it as
// composer-owned focus rather than unmounting on the editor's blur.
await mainInput.press("Shift+Tab");
const preference = mainComposer.getByTestId(
"mention-keep-agents-pinned-toggle",
);
await expect(preference).toBeFocused();
await expect(preference).toHaveAttribute("data-state", "checked");
await page.keyboard.press("Space");
await expect(preference).toHaveAttribute("data-state", "unchecked");
await expect(list).toBeVisible();
expect(await mainComposer.getAttribute("data-mention-menu-unmounted")).toBe(
"false",
);
// Escape is the way back out: focus returns to the editor and the menu
// closes, rather than stranding focus on a control that just unmounted.
await page.keyboard.press("Escape");
await expect(mainInput).toBeFocused();
await expect(list).toHaveCount(0);
// Forward Tab is unchanged by the Shift+Tab route.
await mainInput.fill("@Morg");
await expect(list).toBeVisible();
await mainInput.press("Tab");
await expect(mainInput).toHaveText("@Morgarita ");
await expect(list).toHaveCount(0);
// Ownership is still per-composer: focus moving to a sibling composer hides
// this composer's menu, which is what stops a background composer from
// resurrecting a stale one. Programmatic focus, not a click — a pointerdown
// would dismiss the menu through its outside-press handler and mask the gate
// under test.
await mainInput.fill("@Mor");
await expect(list).toBeVisible();
const rootInput = channelComposer(page).getByTestId("message-input");
await rootInput.focus();
await expect(rootInput).toBeFocused();
await expect(list).toHaveCount(0);
});
test("a failed always-mentioned send shakes the composer avatar without replaying its selection animation", async ({
page,
}) => {
await installAudienceFixtures(page, {
sendMessageDelayMs: 500,
sendMessageErrors: ["simulated send failure"],
});
await openThread(page);
const composer = threadComposer(page);
await automaticallyMention(composer, "Morgarita");
const input = composer.getByTestId("message-input");
const avatar = composer.getByTestId(`composer-address-lock-${AGENT_A}`);
const initialPulseVersion = Number(
await avatar.getAttribute("data-pulse-version"),
);
await expect(avatar).toHaveAttribute("data-shake-version", "0");
await input.type("please retry");
await input.press("Enter");
await expect(avatar).toHaveAttribute(
"data-pulse-version",
String(initialPulseVersion),
{ timeout: 500 },
);
await expect(input).toHaveText("@Morgarita please retry");
await expect(avatar).toHaveAttribute("data-shake-version", "1");
});
test("a manual mention persists when automatic mentions are enabled", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 });
await openThread(page);
const composer = threadComposer(page);
const input = await readyThreadComposerInput(page);
await input.fill("@Mor");
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
await input.press("Tab");
await expect(input).toHaveText("@Morgarita ");
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toBeVisible();
const autoPinConfirmation = page.getByTestId(
"composer-auto-pin-confirmation",
);
await expect(autoPinConfirmation).toContainText(
"Morgarita will be mentioned automatically",
);
await expect(autoPinConfirmation).not.toContainText(
"Future messages in this channel will include this agent.",
);
await expect(autoPinConfirmation).toHaveAttribute("data-side", "left");
await expect(autoPinConfirmation.locator("span")).toHaveCSS(
"white-space",
"nowrap",
);
await expect(
page
.locator("[data-sonner-toast]")
.filter({ hasText: "Morgarita will be mentioned automatically" }),
).toHaveCount(0);
const addressControlBox = await composer
.getByTestId("composer-address-locks")
.locator("..")
.boundingBox();
const confirmationBox = await autoPinConfirmation.boundingBox();
expect(addressControlBox).not.toBeNull();
expect(confirmationBox).not.toBeNull();
if (!addressControlBox || !confirmationBox) {
throw new Error("Automatic mention confirmation is not laid out");
}
expect(confirmationBox.x + confirmationBox.width).toBeLessThanOrEqual(
addressControlBox.x,
);
const turnOffAction = autoPinConfirmation.getByRole("button", {
name: "Turn off",
});
await expect(turnOffAction).toHaveRole("button");
await expect(turnOffAction).toHaveText("Turn off");
await input.press("Escape");
await expect(autoPinConfirmation).toHaveCount(0);
await input.type("hello");
await input.press("Enter");
// The editor locks one frame after Enter (isSending disables it, blurring
// it) and unlocks when the delayed send settles. Wait for that whole cycle
// before asserting on the editor; its focus and editability checks all hold
// vacuously in the frame before the lock lands.
const composerForm = composer.getByTestId("message-composer");
await expect(composerForm).toHaveAttribute("data-submit-locked", "true");
await expect(input).toHaveAttribute("contenteditable", "false");
await expect(composerForm).toHaveAttribute("data-submit-locked", "false", {
timeout: 5_000,
});
await expect(input).toHaveAttribute("contenteditable", "true");
await expect(input).toHaveText("@Morgarita ", { timeout: 2_500 });
await expect(input.locator("[data-placeholder]")).toHaveCount(0);
await expect(input).toBeFocused();
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toBeVisible({ timeout: 2_500 });
await expect(autoPinConfirmation).toHaveCount(0);
await expect
.poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello"))
.toContain(AGENT_A);
await expect(input).toHaveAttribute("contenteditable", "true", {
timeout: 2_500,
});
await input.fill("follow up");
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toHaveCount(0);
await input.press("Enter");
await expect
.poll(() => readOutgoingMentionPubkeys(page, "follow up"))
.not.toContain(AGENT_A);
});
test("the auto-pin popover can turn off automatic agent mentions", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
await input.fill("@Mor");
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
await input.press("Tab");
await expect(input).toHaveText("@Morgarita ");
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toBeVisible();
const autoPinConfirmation = page.getByTestId(
"composer-auto-pin-confirmation",
);
await expect(autoPinConfirmation).toContainText(
"Morgarita will be mentioned automatically",
);
await autoPinConfirmation.getByRole("button", { name: "Turn off" }).click();
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
await expect(composer.getByTestId("mention-options-settings")).toBeVisible();
await expect(
composer.getByTestId("mention-keep-agents-pinned-toggle"),
).toHaveAttribute("data-state", "unchecked");
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toHaveCount(0);
await expect(autoPinConfirmation).toHaveCount(0);
await input.press("Escape");
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
});
test("the auto-pin popover remains open while hovered", async ({ page }) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
await input.fill("@Mor");
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
await input.press("Tab");
const autoPinConfirmation = page.getByTestId(
"composer-auto-pin-confirmation",
);
await autoPinConfirmation.hover();
await page.waitForTimeout(4_250);
await expect(autoPinConfirmation).toBeVisible();
await input.press("Escape");
await expect(autoPinConfirmation).toHaveCount(0);
});
test("removing the mention chip dismisses the auto-pin popover", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
await input.fill("@Mor");
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
await input.press("Tab");
const autoPinConfirmation = page.getByTestId(
"composer-auto-pin-confirmation",
);
await expect(autoPinConfirmation).toBeVisible();
const selectAllShortcut = await page.evaluate(() =>
/mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A",
);
await input.press(selectAllShortcut);
await input.press("Backspace");
await expect(input).toHaveText("");
await expect(autoPinConfirmation).toHaveCount(0);
});
test("automatic mentions exist only in thread composers and stay thread-scoped", async ({
page,
}) => {
await installAudienceFixtures(page);
await openGeneral(page);
const rootComposer = channelComposer(page);
await rootComposer.getByTestId("message-insert-mention").click();
await expect(
rootComposer.getByTestId("mention-options-settings"),
).toHaveCount(0);
await expect(
rootComposer.getByRole("button", { name: /^Automatically mention / }),
).toHaveCount(0);
await openThread(page);
await automaticallyMention(threadComposer(page), "Vogue");
await expect(
threadComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`),
).toBeVisible();
await openThread(page, "mock-general-alice");
await expect(
threadComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`),
).toHaveCount(0);
});
test("a root agent mention is explicit for one message and never becomes retained", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page, { agentAName: "claude code" });
await openGeneral(page);
const composer = channelComposer(page);
const input = composer.getByTestId("message-input");
const firstRootMessage = "@claude code one time";
await input.fill("@cla");
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
await input.press("Tab");
await input.pressSequentially(" one time");
await expect(input).toHaveText(firstRootMessage);
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toHaveCount(0);
await input.press("Enter");
await expect(input).toHaveText("");
await expect
.poll(() =>
page.evaluate(
(pubkey) =>
Boolean(
window.__BUZZ_E2E_SIGNED_EVENTS__?.some((event) =>
(event.tags ?? []).some(
(tag) => tag[0] === "p" && tag[1] === pubkey,
),
),
),
AGENT_A,
),
)
.toBe(true);
await input.fill("next root message");
await input.press("Enter");
await expect
.poll(() => readOutgoingMentionPubkeys(page, "next root message"))
.toEqual([]);
});
test("a removed root-inherited agent stays excluded after the thread reopens", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openGeneral(page);
await waitForMockLiveSubscription(page, "general");
const rootId = "c".repeat(64);
const rootContent = "@Morgarita root request";
await page.evaluate(
({ agentPubkey, content, eventId }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content,
id: eventId,
mentionPubkeys: [agentPubkey],
});
},
{ agentPubkey: AGENT_A, content: rootContent, eventId: rootId },
);
await waitForTimelineSettled(page);
const rootRow = page
.getByTestId("message-timeline")
.locator(`[data-testid="message-row"][data-message-id="${rootId}"]`);
await expect(rootRow).toBeVisible();
await rootRow.hover();
await rootRow.getByRole("button", { name: "Reply" }).click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
let composer = threadComposer(page);
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toBeVisible();
await expect(composer.getByTestId("message-input")).toHaveText("@Morgarita ");
await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click();
await expect(composer.getByTestId("message-input")).toHaveText("");
const threadPanel = page.getByTestId("message-thread-panel");
await threadPanel.getByTestId("auxiliary-panel-close").click();
await expect(threadPanel).toBeHidden();
const loadedRootTags = await page.evaluate(async (eventId) => {
const raw = await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_event", {
eventId,
});
return typeof raw === "string"
? (JSON.parse(raw) as { tags?: string[][] }).tags
: null;
}, rootId);
expect(loadedRootTags).toContainEqual(["p", AGENT_A]);
await rootRow.hover();
await rootRow.getByRole("button", { name: "Reply" }).click();
await expect(threadPanel).toBeVisible();
composer = threadComposer(page);
const input = composer.getByTestId("message-input");
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toHaveCount(0);
await expect(input).toHaveText("");
const reply = "plain reply after reopening";
await input.fill(reply);
await input.press("Enter");
await expect
.poll(() => readOutgoingMentionPubkeys(page, reply))
.not.toContain(AGENT_A);
});
test("an unchecked agent remains excluded while automatic mentions stay enabled", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openThread(page);
await automaticallyMention(threadComposer(page), "Morgarita");
const composer = threadComposer(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);
});
test("re-adding a deleted automatic mention restores its automatic mention state immediately", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
await automaticallyMention(composer, "Morgarita");
const selectAllShortcut = await page.evaluate(() =>
/mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A",
);
await input.press(selectAllShortcut);
await input.press("Backspace");
await expect(input).toHaveText("");
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toHaveCount(0);
await input.fill("@Mor");
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
await input.press("Tab");
await expect(input).toHaveText("@Morgarita ");
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toBeVisible();
await input.type("re-added");
await input.press("Enter");
await expect(input).toHaveText("@Morgarita ");
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toBeVisible();
});
test("implicit automatic mentions stay out of persisted drafts", async ({
page,
}) => {
await installAudienceFixtures(page);
await openThread(page);
await automaticallyMention(threadComposer(page), "Morgarita");
const input = threadComposer(page).getByTestId("message-input");
await input.type("draft text");
await openGeneral(page);
await openThread(page);
await expect(input).toHaveText("@Morgarita draft text");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
await input.pressSequentially(" continues");
await expect(input).toHaveText("@Morgarita draft text continues");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect
.poll(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`))
.toBe("draft text continues");
});
test("an authored duplicate leading mention survives draft restoration", async ({
page,
}) => {
await installAudienceFixtures(page);
await openThread(page);
await automaticallyMention(threadComposer(page), "Morgarita");
const input = threadComposer(page).getByTestId("message-input");
await input.pressSequentially("@Morgarita authored duplicate");
await openGeneral(page);
await openThread(page);
await expect(input).toHaveText("@Morgarita authored duplicate");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect
.poll(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`))
.toBe("@Morgarita authored duplicate");
});
test("typed deletion preserves an identical authored mention in drafts", async ({
page,
}) => {
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
await automaticallyMention(composer, "Morgarita");
const selectAllShortcut = await page.evaluate(() =>
/mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A",
);
await input.press(selectAllShortcut);
await input.press("Backspace");
await expect(input).toHaveText("");
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toHaveCount(0);
await input.pressSequentially("@Morgarita manual after typed deletion");
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {
waitUntil: "domcontentloaded",
});
await expect
.poll(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`))
.toBe("@Morgarita manual after typed deletion");
});
test("removing an automatic mention preserves an identical authored mention in drafts", async ({
page,
}) => {
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
await automaticallyMention(composer, "Morgarita");
await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click();
await input.pressSequentially("@Morgarita manual after removal");
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {
waitUntil: "domcontentloaded",
});
await expect
.poll(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`))
.toBe("@Morgarita manual after removal");
});
test("multiple automatic mentions stay out of persisted drafts", async ({
page,
}) => {
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
await automaticallyMention(composer, "Morgarita");
await automaticallyMention(composer, "Vogue");
await input.pressSequentially("draft text");
await openGeneral(page);
await openThread(page);
await expect(input).toHaveText("@Morgarita @Vogue draft text");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(2);
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {
waitUntil: "domcontentloaded",
});
await expect
.poll(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`))
.toBe("draft text");
});
test("re-enabling an automatic mention preserves an authored duplicate after draft restoration", async ({
page,
}) => {
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(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 automaticallyMention(composer, "Morgarita");
await input.pressSequentially("@Morgarita authored duplicate");
await openGeneral(page);
await openThread(page);
await expect(input).toHaveText("@Morgarita authored duplicate");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect
.poll(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`))
.toBe("@Morgarita authored duplicate");
});
test("a restored multi-word automatic mention remains a chip with the caret after its space", async ({
page,
}) => {
await installAudienceFixtures(page, { agentAName: "claude code" });
await openThread(page);
const originalComposer = threadComposer(page);
await automaticallyMention(originalComposer, "claude code");
const originalInput = originalComposer.getByTestId("message-input");
await originalInput.pressSequentially("hello");
await originalInput.press("Enter");
await expect
.poll(() => readOutgoingMentionPubkeys(page, "@claude code hello"))
.toContain(AGENT_A);
await expect(originalInput).toHaveText("@claude code ");
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("chat-title")).toHaveText("random");
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
const expectedContent = "@claude code ";
await expect(input).toHaveText(expectedContent);
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toBeVisible();
await expect(
composer.getByRole("button", { name: "Manage mentions" }),
).toBeVisible();
await page.waitForTimeout(500);
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
await expect(input).toBeFocused();
await expect
.poll(() => readComposerCaret(input))
.toBe(expectedContent.length);
await input.pressSequentially("follow-up");
await expect(input).toHaveText("@claude code follow-up");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toBeVisible();
});
test("reduced motion removes addressed agents without spatial animation", async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
await input.fill("@Mor");
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
await input.press("Tab");
const removeButton = composer.getByTestId(
`composer-address-lock-remove-${AGENT_A}`,
);
await expect(removeButton).toBeVisible();
await expect(removeButton).toHaveAttribute("style", /opacity: 1/);
await expect(removeButton).toHaveCSS("transform", "none");
await removeButton.click();
await expect(input).toHaveText("");
await expect(removeButton).toHaveCount(0);
});
for (const theme of ["buzz", "buzz-dark"]) {
test(`captures the mention-button placement in ${theme}`, async ({
page,
}) => {
await seedTheme(page, theme);
await installAudienceFixtures(page);
await openThread(page);
const overlay = threadComposer(page);
const composer = overlay.getByTestId("message-composer");
await automaticallyMention(overlay, "Morgarita");
await automaticallyMention(overlay, "Vogue");
await overlay.getByTestId("message-input").focus();
await waitForAnimations(page);
await composer.screenshot({ path: `${SHOTS}/${theme}-mention-button.png` });
});
}
test("the mention-button placement fits the narrow composer", async ({
page,
}) => {
await page.setViewportSize({ width: 700, height: 760 });
await installAudienceFixtures(page);
await openThread(page);
const overlay = threadComposer(page);
const composer = overlay.getByTestId("message-composer");
await automaticallyMention(overlay, "Morgarita");
await automaticallyMention(overlay, "Vogue");
await expect(overlay.getByTestId("composer-address-locks")).toBeVisible();
await expect(
overlay.getByRole("button", { name: "Manage mentions" }),
).toBeVisible();
await waitForAnimations(page);
await composer.screenshot({ path: `${SHOTS}/narrow-mention-button.png` });
});
test("captures the lightweight auto-pin popover", async ({ page }) => {
await seedTheme(page, "buzz-dark");
await installAudienceFixtures(page);
await openThread(page);
const composer = threadComposer(page);
const input = composer.getByTestId("message-input");
await input.fill("draft text");
await pressPrimaryShiftM(page);
await expect(input).toHaveText("@alice draft text");
const addressControl = composer
.getByTestId("composer-address-locks")
.locator("..");
const confirmation = page.getByTestId("composer-auto-pin-confirmation");
await expect(confirmation).toContainText(
"alice will be mentioned automatically",
);
await waitForAnimations(page);
const addressBox = await addressControl.boundingBox();
const confirmationBox = await confirmation.boundingBox();
expect(addressBox).not.toBeNull();
expect(confirmationBox).not.toBeNull();
if (!addressBox || !confirmationBox) {
throw new Error("Popover is not laid out");
}
const left = addressBox.x - 14;
const top = Math.min(addressBox.y, confirmationBox.y) - 14;
const right = confirmationBox.x + confirmationBox.width + 14;
const bottom =
Math.max(
addressBox.y + addressBox.height,
confirmationBox.y + confirmationBox.height,
) + 14;
await page.screenshot({
path: `${SHOTS}/auto-pin-popover-dark.png`,
clip: { x: left, y: top, width: right - left, height: bottom - top },
});
});