fix(desktop-messages): preserve inline agent mentions with persistent addressing (#6793)

**Category:** fix
**User Impact:** Users can place `@Agent` mentions inline anywhere in a
message—even when that agent is also persistently addressed—so separate
agents can receive separate instructions in the same message.

**Problem:** Selecting an agent from the composer mention picker could
turn the selection into persistent addressing instead of leaving an
inline `@Agent` at the cursor. That made persistent addressing and
inline composition mutually exclusive: once the persistent behavior took
over, users lost the clear `@Agent A do X, @Agent B do Y` message
structure they previously had.

**Solution:** Make inline mentioning and persistent addressing
independent behaviors:

- Ordinary mention selections always insert `@Agent` inline at the
current cursor position.
- This remains true when the agent is already persistently addressed;
the persistent audience never blocks or replaces an inline mention.
- If **Automatically mention agents** is enabled, a successfully sent
inline mention may additionally make that agent persistent for later
messages. Existing saved preferences remain respected.
- The dedicated automatic-mention control and primary+Shift+Enter
shortcut continue to add or remove persistent addressing directly.
- For users without a saved preference, **Automatically mention agents**
now defaults off.

<details>
<summary>File changes</summary>


**desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs**
Updates preference coverage for the default-off behavior while
preserving explicit saved choices.


**desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts**
Defaults automatic post-send persistence off when no valid preference
exists.

**desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs**
Verifies ordinary picker selections insert inline mentions without
changing or pulsing the persistent audience, including when the selected
agent is already persistently addressed.

**desktop/src/features/messages/ui/useAgentAddressLockPicker.ts**
Keeps ordinary mention selection on the existing inline insertion path
while preserving the separate persistent-address controls and shortcut.

**desktop/tests/e2e/persistent-agent-audience.spec.ts**
Covers separately targeted inline instructions to two agents, signed
outgoing recipients, default-off persistence, and explicit opt-in
persistence scenarios.

</details>

## Reproduction steps

### Inline mentions without persistence

1. Open a channel with at least two available agents and leave
**Automatically mention agents** disabled.
2. Use the composer mention picker to insert the first agent, type an
instruction, then insert a second agent and type a different
instruction.
3. Confirm the draft reads like `@Agent A review this, @Agent B test
that` and neither agent appears in the persistent addressed-agent
controls.
4. Send the message and confirm both agents are recipients while neither
remains persistently addressed for the next draft.

### Inline mentions with persistence

1. Enable **Automatically mention agents**, then mention an agent inline
and send successfully.
2. Confirm that agent becomes persistently addressed for later messages.
3. In a new draft, select the same agent from the mention picker again.
4. Confirm a new inline `@Agent` is inserted at the cursor while the
agent remains persistently addressed.
5. Confirm the dedicated automatic-mention control and
primary+Shift+Enter shortcut can still add or remove persistent
addressing directly.

## Demo

- **Before:** Persistent addressing could consume an ordinary picker
selection, preventing users from placing that agent inline in the
message.
- **After:** Ordinary selection always produces an inline `@Agent`;
persistence is a separate optional behavior that can coexist with inline
mentions.

### Related issue

N/A — reported through the Buzz feature room.

### Testing

At `a4579c0a666f3644abe44271614186d7b9356bf5`:

- Post-push hooks passed desktop check, desktop typecheck, and the full
desktop unit suite.
- The two persistence-focused Playwright smoke scenarios passed after
explicitly opting into **Automatically mention agents**.
- The one-time multi-agent targeting and persistent-address shortcut
Playwright scenarios passed on the feature changes before the final
rebase; CI is validating the rebased branch.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
This commit is contained in:
Taylor Ho
2026-08-25 13:32:09 -07:00
committed by GitHub
parent f249710331
commit b58de7cfa7
8 changed files with 373 additions and 56 deletions

View File

@@ -9,25 +9,25 @@ globalThis.localStorage = {
const preference = await import("./autoPinMentionedAgentsPreference.ts");
test("defaults missing and invalid values to keeping mentioned agents pinned", () => {
assert.equal(preference.parseKeepMentionedAgentsPinned(null), true);
assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), true);
test("defaults missing and invalid values to one-time agent mentions", () => {
assert.equal(preference.parseKeepMentionedAgentsPinned(null), false);
assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), false);
assert.equal(preference.parseKeepMentionedAgentsPinned("true"), true);
assert.equal(preference.parseKeepMentionedAgentsPinned("false"), false);
});
test("persists changes to the post-mention pinning preference", () => {
preference.setKeepMentionedAgentsPinned(false);
assert.equal(preference.getKeepMentionedAgentsPinned(), false);
assert.equal(
values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY),
"false",
);
preference.setKeepMentionedAgentsPinned(true);
assert.equal(preference.getKeepMentionedAgentsPinned(), true);
assert.equal(
values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY),
"true",
);
preference.setKeepMentionedAgentsPinned(false);
assert.equal(preference.getKeepMentionedAgentsPinned(), false);
assert.equal(
values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY),
"false",
);
});

View File

@@ -2,7 +2,7 @@ import * as React from "react";
export const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY =
"buzz.messages.keepMentionedAgentsPinned";
export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = true;
export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = false;
const listeners = new Set<() => void>();
let keepMentionedAgentsPinned = readStoredPreference();

View File

@@ -114,6 +114,10 @@ function storageKey(): string {
: legacyStorageKey();
}
export function getDraftStoreScope(): string {
return storageKey();
}
function legacyStorageKey(): string {
return `${LEGACY_DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`;
}

View File

@@ -170,32 +170,33 @@ function MessageComposerImpl({
media.queuedAttachmentsRef.current.length === 0;
const ownsDropZone = mediaController === undefined;
const backgroundUpload = useBackgroundMediaUpload();
useDraftPersistLifecycle({
effectiveDraftKey,
channelId,
loadDraft: drafts.loadDraft,
persistDraft: drafts.persistDraft,
getMentionRefs: mentions.getDraftMentionRefs,
restoreMentionRefs: mentions.restoreDraftMentionRefs,
livePendingImeta: media.pendingImeta,
setPendingImeta: media.setPendingImeta,
getQueuedAttachments: () => media.queuedAttachmentsRef.current,
saveQueuedAttachmentsForDraft,
clearQueuedAttachments: media.clearQueuedAttachments,
restoreQueuedAttachments: media.restoreQueuedAttachments,
takeQueuedAttachmentsForDraft,
setContent: (content) => {
setComposerContent(content);
richText.setContent(content);
},
clearContent: () => {
setComposerContent("");
richText.clearContent();
},
setSpoileredAttachmentUrls,
spoileredAttachmentUrlsRef,
syncComposerContentFromEditor,
});
const { trackAuthoredContent: trackDraftAuthoredContent } =
useDraftPersistLifecycle({
effectiveDraftKey,
channelId,
loadDraft: drafts.loadDraft,
persistDraft: drafts.persistDraft,
getMentionRefs: mentions.getDraftMentionRefs,
restoreMentionRefs: mentions.restoreDraftMentionRefs,
livePendingImeta: media.pendingImeta,
setPendingImeta: media.setPendingImeta,
getQueuedAttachments: () => media.queuedAttachmentsRef.current,
saveQueuedAttachmentsForDraft,
clearQueuedAttachments: media.clearQueuedAttachments,
restoreQueuedAttachments: media.restoreQueuedAttachments,
takeQueuedAttachmentsForDraft,
setContent: (content) => {
setComposerContent(content);
richText.setContent(content);
},
clearContent: () => {
setComposerContent("");
richText.clearContent();
},
setSpoileredAttachmentUrls,
spoileredAttachmentUrlsRef,
syncComposerContentFromEditor,
});
// biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
React.useEffect(() => {
media.setUploadState({ status: "idle" });
@@ -272,6 +273,8 @@ function MessageComposerImpl({
onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false,
onUpdate: ({ cursor, linkPreviewContent, text }) => {
trackDraftAuthoredContent(text);
contentRef.current = text;
setComposerContentFromText(text);
setPreviewContent(linkPreviewContent);
if (!isSubmitLockedRef.current && !editTargetRef.current) {

View File

@@ -585,6 +585,225 @@ test("draft_lifecycle_empty_target_clears_stale_mention_refs", async () => {
await handle.unmount();
});
test("draft_lifecycle_persists_an_explicit_clear_before_async_rerender", async () => {
const DRAFT_KEY = "chan-clear-race";
setupStore("pubkey-clear-race");
persistDraftEntry(DRAFT_KEY, "draft text", DRAFT_KEY, [], []);
let editorContent = "";
let trackAuthoredContent;
const spoileredRef = { current: new Set() };
function HarnessComposer() {
({ trackAuthoredContent } = useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: [],
setPendingImeta: () => {},
setContent: (content) => {
editorContent = content;
},
clearContent: () => {
editorContent = "";
},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: spoileredRef,
syncComposerContentFromEditor: () => editorContent,
}));
return null;
}
const handle = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "draft text");
trackAuthoredContent("");
assert.equal(
loadDraftEntry(DRAFT_KEY),
undefined,
"the authoritative update removes the stale body even while editor reads lag",
);
await handle.unmount();
assert.equal(
loadDraftEntry(DRAFT_KEY),
undefined,
"async settlement must not repersist the deleted body",
);
const remounted = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "", "the deleted body must not be restored");
await remounted.unmount();
});
test("draft_lifecycle_clear_caption_preserves_image_and_spoiler_on_remount", async () => {
const DRAFT_KEY = "chan-clear-caption-image";
setupStore("pubkey-clear-caption-image");
persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [IMG_A], [IMG_A.url]);
let editorContent = "";
let pendingImeta = [];
let spoileredUrls = new Set();
let trackAuthoredContent;
function HarnessComposer() {
({ trackAuthoredContent } = useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: pendingImeta,
setPendingImeta: (imeta) => {
pendingImeta = imeta;
},
setContent: (content) => {
editorContent = content;
},
clearContent: () => {
editorContent = "";
},
setSpoileredAttachmentUrls: (urls) => {
spoileredUrls = urls;
},
spoileredAttachmentUrlsRef: {
get current() {
return spoileredUrls;
},
},
syncComposerContentFromEditor: () => editorContent,
}));
return null;
}
const handle = await mountStrictMode(HarnessComposer);
trackAuthoredContent("");
editorContent = "";
await handle.unmount();
const remounted = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "");
assert.equal(pendingImeta[0]?.url, IMG_A.url);
assert.deepEqual([...spoileredUrls], [IMG_A.url]);
assert.equal(loadDraftEntry(DRAFT_KEY)?.content, "");
await remounted.unmount();
});
test("draft_lifecycle_clear_caption_preserves_queued_file_on_remount", async () => {
const DRAFT_KEY = "chan-clear-caption-file";
setupStore("pubkey-clear-caption-file");
persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [], []);
const FILE_A = {
file: new File(["report"], "report.pdf", { type: "application/pdf" }),
id: 9,
spoilered: true,
};
let editorContent = "";
let queuedAttachments = [FILE_A];
let trackAuthoredContent;
const spoileredRef = { current: new Set() };
function HarnessComposer() {
({ trackAuthoredContent } = useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: [],
setPendingImeta: () => {},
getQueuedAttachments: () => queuedAttachments,
saveQueuedAttachmentsForDraft,
clearQueuedAttachments: () => {
queuedAttachments = [];
},
restoreQueuedAttachments: (attachments) => {
queuedAttachments = attachments;
},
takeQueuedAttachmentsForDraft,
setContent: (content) => {
editorContent = content;
},
clearContent: () => {
editorContent = "";
},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: spoileredRef,
syncComposerContentFromEditor: () => editorContent,
}));
return null;
}
saveQueuedAttachmentsForDraft(DRAFT_KEY, [FILE_A]);
const handle = await mountStrictMode(HarnessComposer);
trackAuthoredContent("");
editorContent = "";
await handle.unmount();
const remounted = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "");
assert.equal(queuedAttachments[0]?.file.name, "report.pdf");
assert.equal(queuedAttachments[0]?.spoilered, true);
await remounted.unmount();
});
test("draft_lifecycle_clear_authority_is_scoped_to_relay_and_identity", async () => {
const DRAFT_KEY = "shared-key";
installFreshLocalStorage();
clearAllDrafts();
initDraftStore("pubkey-a", "wss://relay-a.example");
persistDraftEntry(DRAFT_KEY, "workspace A", DRAFT_KEY, [], []);
let editorContent = "";
let trackAuthoredContent;
const spoileredRef = { current: new Set() };
function HarnessComposer() {
({ trackAuthoredContent } = useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: [],
setPendingImeta: () => {},
setContent: (content) => {
editorContent = content;
},
clearContent: () => {
editorContent = "";
},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: spoileredRef,
syncComposerContentFromEditor: () => editorContent,
}));
return null;
}
const workspaceA = await mountStrictMode(HarnessComposer);
trackAuthoredContent("");
editorContent = "";
await workspaceA.unmount();
initDraftStore("pubkey-b", "wss://relay-b.example");
persistDraftEntry(DRAFT_KEY, "workspace B", DRAFT_KEY, [], []);
const workspaceB = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "workspace B");
await workspaceB.unmount();
assert.equal(loadDraftEntry(DRAFT_KEY)?.content, "workspace B");
initDraftStore("pubkey-a", "wss://relay-a.example");
const workspaceARemount = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "", "workspace A stale text must stay cleared");
await workspaceARemount.unmount();
});
test("draft_lifecycle_preserves_local_files_across_a_b_a_switch", async () => {
setupStore("pubkey-switch-files");
const FILE_A = {

View File

@@ -2,9 +2,10 @@ import * as React from "react";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore";
import type {
DraftMentionRef,
DraftState,
import {
getDraftStoreScope,
type DraftMentionRef,
type DraftState,
} from "@/features/messages/lib/useDrafts";
type UseDraftPersistLifecycleParams = {
@@ -60,6 +61,21 @@ type UseDraftPersistLifecycleParams = {
syncComposerContentFromEditor: () => string;
};
type UseDraftPersistLifecycleResult = {
/**
* Record the latest authored editor content. Empty content is persisted
* immediately and remains authoritative across composer remounts until a
* later non-empty editor update supersedes it.
*/
trackAuthoredContent: (content: string) => void;
};
const authoritativelyClearedDraftKeys = new Set<string>();
function scopedDraftKey(draftKey: string): string {
return `${getDraftStoreScope()}:${draftKey}`;
}
/**
* Owns the draft-persist lifecycle for `MessageComposer`.
*
@@ -104,8 +120,10 @@ export function useDraftPersistLifecycle({
setSpoileredAttachmentUrls,
spoileredAttachmentUrlsRef,
syncComposerContentFromEditor,
}: UseDraftPersistLifecycleParams): void {
}: UseDraftPersistLifecycleParams): UseDraftPersistLifecycleResult {
const pendingImetaForPersistRef = React.useRef<ImetaMedia[]>([]);
const emptyContentIsAuthoritativeRef = React.useRef(false);
const isRestoringContentRef = React.useRef(false);
const restoredQueuedAttachmentsRef = React.useRef<QueuedMediaAttachment[]>(
[],
);
@@ -117,7 +135,7 @@ export function useDraftPersistLifecycle({
pendingImetaForPersistRef.current = livePendingImeta;
// biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
React.useEffect(() => {
React.useLayoutEffect(() => {
// The outgoing draft is persisted by the cleanup below, which runs before
// this body on key changes and has the correct outgoing channelId in its
// closure. Do NOT re-persist prevKey here: channelId in this render
@@ -134,10 +152,21 @@ export function useDraftPersistLifecycle({
: [];
}
restoreQueuedAttachments?.(restoredQueuedAttachmentsRef.current);
const authoritativeDraftKey = effectiveDraftKey
? scopedDraftKey(effectiveDraftKey)
: null;
const wasAuthoritativelyCleared = authoritativeDraftKey
? authoritativelyClearedDraftKeys.has(authoritativeDraftKey)
: false;
const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined;
emptyContentIsAuthoritativeRef.current = wasAuthoritativelyCleared;
isRestoringContentRef.current = true;
if (saved) {
setContent(saved.content);
restoreMentionRefs(saved.mentionRefs ?? []);
const restoredContent = wasAuthoritativelyCleared ? "" : saved.content;
setContent(restoredContent);
restoreMentionRefs(
wasAuthoritativelyCleared ? [] : (saved.mentionRefs ?? []),
);
// Set the persist-snapshot ref SYNCHRONOUSLY before calling the async
// state setter, so the cleanup closure (which may fire before the state
// update commits in React StrictMode's simulate-unmount pass) reads the
@@ -153,6 +182,7 @@ export function useDraftPersistLifecycle({
setPendingImeta([]);
setSpoileredAttachmentUrls(new Set());
}
isRestoringContentRef.current = false;
return () => {
if (effectiveDraftKey) {
@@ -160,7 +190,9 @@ export function useDraftPersistLifecycle({
if (queuedAttachments.length > 0) {
saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments);
}
const content = syncComposerContentFromEditor();
const content = emptyContentIsAuthoritativeRef.current
? ""
: syncComposerContentFromEditor();
persistDraft(
effectiveDraftKey,
content,
@@ -172,4 +204,29 @@ export function useDraftPersistLifecycle({
}
};
}, [effectiveDraftKey]);
const trackAuthoredContent = React.useCallback(
(content: string) => {
if (!effectiveDraftKey || isRestoringContentRef.current) return;
const authoritativeDraftKey = scopedDraftKey(effectiveDraftKey);
if (content.length > 0) {
authoritativelyClearedDraftKeys.delete(authoritativeDraftKey);
emptyContentIsAuthoritativeRef.current = false;
return;
}
authoritativelyClearedDraftKeys.add(authoritativeDraftKey);
emptyContentIsAuthoritativeRef.current = true;
persistDraft(
effectiveDraftKey,
content,
channelId ?? effectiveDraftKey,
[...pendingImetaForPersistRef.current],
[...spoileredAttachmentUrlsRef.current],
[],
);
},
[channelId, effectiveDraftKey, persistDraft, spoileredAttachmentUrlsRef],
);
return { trackAuthoredContent };
}

View File

@@ -1260,7 +1260,7 @@ test("selecting a persona mention reuses an existing persona agent", async ({
await expect(mentionChip).toHaveText("Fizz");
});
test("managed relay-profile agents with member roles use the agent address tray", async ({
test("managed relay-profile agents with member roles can be addressed explicitly", async ({
page,
}) => {
await installMockBridge(page, {
@@ -1286,9 +1286,17 @@ test("managed relay-profile agents with member roles use the agent address tray"
await input.fill("@char");
const dropdown = autocomplete(page);
await expect(dropdown.getByText("charlie")).toBeVisible();
await expect(dropdown.getByText("agent")).toBeVisible();
await input.press("Enter");
const charlieRow = dropdown.getByTestId(
`mention-suggestion-${TEST_IDENTITIES.charlie.pubkey}`,
);
await expect(charlieRow.getByText("charlie")).toBeVisible();
await expect(charlieRow.getByText("agent")).toBeVisible();
await charlieRow
.getByRole("button", {
name: "Automatically mention charlie",
exact: true,
})
.click();
await expect(input).toHaveText("@charlie ");
await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie");
@@ -2618,7 +2626,7 @@ test("system member-joined rows render the joined person as a plain profile name
await expect(joinedPersonName).not.toHaveAttribute("data-mention");
});
test("selecting a managed non-member agent from a DM addresses it", async ({
test("a managed non-member agent from a DM can be addressed explicitly", async ({
page,
}) => {
await installMockBridge(page, {
@@ -2638,10 +2646,18 @@ test("selecting a managed non-member agent from a DM addresses it", async ({
await input.fill("@char");
const dropdown = autocomplete(page);
await expect(dropdown.getByText("charlie")).toBeVisible();
const charlieRow = dropdown.getByTestId(
`mention-suggestion-${TEST_IDENTITIES.charlie.pubkey}`,
);
await expect(charlieRow.getByText("charlie")).toBeVisible();
await expect(autocomplete(page)).toHaveCount(1);
await expect(input.locator(".mention-chip")).toHaveCount(0);
await input.press("Enter");
await charlieRow
.getByRole("button", {
name: "Automatically mention charlie",
exact: true,
})
.click();
await expect(input).toHaveText("@charlie ");
await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie");

View File

@@ -8,6 +8,20 @@ const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
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(
@@ -271,7 +285,7 @@ test("automatically mentions multiple agents from the mention picker", async ({
).toBeVisible();
});
test("Tab immediately selects a manually mentioned agent", async ({ page }) => {
test("Tab inserts a one-time agent mention by default", async ({ page }) => {
await installAudienceFixtures(page);
await openGeneral(page);
@@ -286,7 +300,7 @@ test("Tab immediately selects a manually mentioned agent", async ({ page }) => {
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toBeVisible();
).toHaveCount(0);
const selectAllShortcut = await page.evaluate(() =>
/mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A",
);
@@ -301,6 +315,7 @@ test("Tab immediately selects a manually mentioned agent", async ({ page }) => {
test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openGeneral(page);
@@ -448,7 +463,7 @@ test("the mention button opens settings and can undo an address", async ({
.toContain(AGENT_A);
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
).toHaveCount(1);
).toHaveCount(0);
});
test("always-mentioned agents remain in the mention button while Enter-send resolves", async ({
@@ -568,9 +583,10 @@ test("a failed always-mentioned send shakes the composer avatar", async ({
await expect(avatar).toHaveAttribute("data-shake-version", "1");
});
test("a manually mentioned agent becomes selected immediately", async ({
test("a manual mention persists when automatic mentions are enabled", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 });
await openGeneral(page);
@@ -652,6 +668,7 @@ test("a manually mentioned agent becomes selected immediately", async ({
test("the auto-pin popover can turn off automatic agent mentions", async ({
page,
}) => {
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openGeneral(page);
@@ -717,6 +734,7 @@ test("reduced motion removes addressed agents without spatial animation", async
page,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await keepMentionedAgentsPinned(page);
await installAudienceFixtures(page);
await openGeneral(page);