mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -114,6 +114,10 @@ function storageKey(): string {
|
||||
: legacyStorageKey();
|
||||
}
|
||||
|
||||
export function getDraftStoreScope(): string {
|
||||
return storageKey();
|
||||
}
|
||||
|
||||
function legacyStorageKey(): string {
|
||||
return `${LEGACY_DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user