diff --git a/desktop/src/features/messages/ui/ComposerAudienceChips.tsx b/desktop/src/features/messages/ui/ComposerAudienceChips.tsx
new file mode 100644
index 0000000000..9762e200f9
--- /dev/null
+++ b/desktop/src/features/messages/ui/ComposerAudienceChips.tsx
@@ -0,0 +1,42 @@
+import { X } from "lucide-react";
+
+export type ComposerAudienceChip = {
+ displayName: string;
+ pubkey: string;
+};
+
+export function ComposerAudienceChips({
+ audience,
+ onRemove,
+}: {
+ audience: readonly ComposerAudienceChip[];
+ onRemove: (pubkey: string) => void;
+}) {
+ if (audience.length === 0) return null;
+
+ return (
+
+ {audience.map(({ displayName, pubkey }) => (
+
+ {displayName}
+
+
+ ))}
+
+ );
+}
diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx
index 69e4ec67b5..b434188e89 100644
--- a/desktop/src/features/messages/ui/MessageComposer.tsx
+++ b/desktop/src/features/messages/ui/MessageComposer.tsx
@@ -50,6 +50,7 @@ import {
type MentionSuggestion,
} from "./MentionAutocomplete";
import { ComposerDockToolbar } from "./ComposerDockToolbar";
+import { ComposerAudienceChips } from "./ComposerAudienceChips";
import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
import { useMentionSendFlow } from "./useMentionSendFlow";
import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration";
@@ -292,7 +293,6 @@ function MessageComposerImpl({
persistentMentionHydration,
);
persistentMentionHydrationRef.current = persistentMentionHydration;
-
const mentionSendFlow = useMentionSendFlow({
channelId,
channelLinks,
@@ -609,8 +609,8 @@ function MessageComposerImpl({
),
spoileredAttachmentUrls,
trimmed,
- audienceGeneration: persistentAudience.generation,
- audienceRevision: audienceScope ? persistentAudience.revision : null,
+ audienceGeneration: persistentMentionHydration.audience.generation,
+ audienceRevision: persistentMentionHydration.getAudienceRevision(),
});
} finally {
persistentMentionHydration.endSubmit();
@@ -634,10 +634,7 @@ function MessageComposerImpl({
syncComposerContentFromEditor,
onCaptureSendContext,
onPreparingMentionSendChange,
- audienceScope,
persistentMentionHydration,
- persistentAudience.generation,
- persistentAudience.revision,
]);
submitMessageRef.current = submitMessage;
@@ -955,6 +952,9 @@ function MessageComposerImpl({
) : null}
+
{(media.pendingImeta.length > 0 || media.isUploading) && (
diff --git a/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.test.mjs b/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.test.mjs
new file mode 100644
index 0000000000..bde7307897
--- /dev/null
+++ b/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.test.mjs
@@ -0,0 +1,51 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { truncatePubkey } from "@/shared/lib/pubkey";
+
+import {
+ getPersistentMentionTokenRemovalRange,
+ resolvePersistentMentionTargets,
+} from "./usePersistentAgentMentionHydration.ts";
+
+const agentA = "a".repeat(64);
+const agentB = "b".repeat(64);
+
+test("persistent hydration gives duplicate agent names identity-safe mention text", () => {
+ const targets = resolvePersistentMentionTargets(
+ [agentA, agentB],
+ () => "Morgarita",
+ );
+
+ assert.deepEqual(targets, [
+ { pubkey: agentA, displayName: `Morgarita (${truncatePubkey(agentA)})` },
+ { pubkey: agentB, displayName: `Morgarita (${truncatePubkey(agentB)})` },
+ ]);
+});
+
+test("persistent mention removal targets the duplicate-name pubkey's exact label", () => {
+ const targets = resolvePersistentMentionTargets(
+ [agentA, agentB],
+ () => "Morgarita",
+ );
+ const hydratedLabels = new Map(
+ targets.map((target) => [target.pubkey, target.displayName]),
+ );
+ const text = `${targets.map((target) => `@${target.displayName}`).join(" ")} `;
+
+ const range = getPersistentMentionTokenRemovalRange(
+ text,
+ agentB,
+ hydratedLabels,
+ () => "Morgarita",
+ );
+
+ assert.deepEqual(range, {
+ from: `@${targets[0].displayName} `.length,
+ to: text.length,
+ });
+ assert.equal(
+ text.slice(0, range.from) + text.slice(range.to),
+ `@${targets[0].displayName} `,
+ );
+});
diff --git a/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts b/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts
index d2e0d4390e..acc3e374e9 100644
--- a/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts
+++ b/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts
@@ -1,8 +1,60 @@
import * as React from "react";
-import { usePersistentAgentAudience } from "@/features/messages/lib/persistentAgentAudience";
+import { getMentionOffset } from "@/features/messages/lib/hasMention";
+import {
+ getPersistentAgentAudienceRevision,
+ usePersistentAgentAudience,
+} from "@/features/messages/lib/persistentAgentAudience";
import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor";
+import { truncatePubkey } from "@/shared/lib/pubkey";
+
+const RECONCILE_DELAY_MS = 150;
+
+export type PersistentMentionTarget = {
+ displayName: string;
+ pubkey: string;
+};
+
+export function resolvePersistentMentionTargets(
+ pubkeys: Iterable,
+ getDisplayName: (pubkey: string) => string | null,
+): PersistentMentionTarget[] {
+ const targets = [...new Set(pubkeys)]
+ .map((pubkey) => ({ pubkey, displayName: getDisplayName(pubkey) }))
+ .filter(
+ (target): target is PersistentMentionTarget =>
+ target.displayName !== null,
+ );
+ const nameCounts = new Map();
+ for (const { displayName } of targets) {
+ const key = displayName.trim().toLowerCase();
+ nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1);
+ }
+ return targets.map((target) =>
+ (nameCounts.get(target.displayName.trim().toLowerCase()) ?? 0) > 1
+ ? {
+ ...target,
+ displayName: `${target.displayName} (${truncatePubkey(target.pubkey)})`,
+ }
+ : target,
+ );
+}
+
+export function getPersistentMentionTokenRemovalRange(
+ text: string,
+ pubkey: string,
+ hydratedLabels: ReadonlyMap,
+ getDisplayName: (pubkey: string) => string | null,
+): { from: number; to: number } | null {
+ const displayName = hydratedLabels.get(pubkey) ?? getDisplayName(pubkey);
+ if (!displayName) return null;
+ const from = getMentionOffset(text, displayName);
+ if (from === null) return null;
+ let to = from + displayName.length + 1;
+ if (text[to] === " ") to += 1;
+ return { from, to };
+}
export function usePersistentAgentMentionHydration({
audienceScope,
@@ -20,6 +72,20 @@ export function usePersistentAgentMentionHydration({
richText: UseRichTextEditorResult;
}) {
const audience = usePersistentAgentAudience(audienceScope);
+ const {
+ enabled: audienceEnabled,
+ initialize,
+ pubkeys: audiencePubkeys,
+ } = audience;
+ const {
+ cancelMentionAutocomplete,
+ clearMentions,
+ extractMentionPubkeys,
+ getMentionDisplayName,
+ insertResolvedMention,
+ registerMentionPubkey,
+ } = mentions;
+ const { getPlainTextAndCursor, replacePlainTextRange } = richText;
const audienceRef = React.useRef(audience);
audienceRef.current = audience;
const scopeRef = React.useRef(audienceScope);
@@ -28,56 +94,70 @@ export function usePersistentAgentMentionHydration({
isEditingRef.current = isEditing;
React.useEffect(() => {
if (!audienceScope || !initialAgentPubkeys) return;
- audience.initialize(initialAgentPubkeys);
- }, [audience.initialize, audienceScope, initialAgentPubkeys]);
+ initialize(initialAgentPubkeys);
+ }, [audienceScope, initialize, initialAgentPubkeys]);
const isRestoringRef = React.useRef(false);
const isSubmittingRef = React.useRef(false);
+ const isMentionOpenRef = React.useRef(mentions.isMentionOpen);
+ isMentionOpenRef.current = mentions.isMentionOpen;
const cancelHydrationAutocompleteRef = React.useRef(false);
const hydratedRef = React.useRef(false);
+ const hydratedMentionLabelsRef = React.useRef(new Map());
+ const reconcileTimerRef = React.useRef | null>(
+ null,
+ );
+
+ const cancelReconcile = React.useCallback(() => {
+ if (reconcileTimerRef.current === null) return;
+ clearTimeout(reconcileTimerRef.current);
+ reconcileTimerRef.current = null;
+ }, []);
+
+ React.useEffect(() => cancelReconcile, [cancelReconcile]);
const hydrate = React.useCallback(() => {
const capturedScope = audienceScope;
if (
- !audience.enabled ||
+ !audienceEnabled ||
!capturedScope ||
isEditingRef.current ||
- audience.pubkeys.length === 0
+ audiencePubkeys.length === 0
) {
+ hydratedMentionLabelsRef.current.clear();
hydratedRef.current = true;
return;
}
isRestoringRef.current = true;
- const current = richText.getPlainTextAndCursor().text;
- const targets = audience.pubkeys
- .map((pubkey) => ({
- pubkey,
- displayName: mentions.getMentionDisplayName(pubkey),
- }))
- .filter((target): target is { pubkey: string; displayName: string } =>
- Boolean(target.displayName),
- );
+ const current = getPlainTextAndCursor().text;
+ const targets = resolvePersistentMentionTargets(
+ audiencePubkeys,
+ getMentionDisplayName,
+ );
+ hydratedMentionLabelsRef.current = new Map(
+ targets.map((target) => [target.pubkey, target.displayName]),
+ );
for (const target of targets)
- mentions.registerMentionPubkey(target.displayName, target.pubkey, {
+ registerMentionPubkey(target.displayName, target.pubkey, {
isAgent: true,
});
if (scopeRef.current !== capturedScope) {
isRestoringRef.current = false;
return;
}
- const present = new Set(mentions.extractMentionPubkeys(current));
+ const present = new Set(extractMentionPubkeys(current));
let prefixLength = 0;
for (const target of targets.filter(
(candidate) => !present.has(candidate.pubkey),
)) {
if (scopeRef.current !== capturedScope) break;
- const edit = mentions.insertResolvedMention({
+ const edit = insertResolvedMention({
...target,
isAgent: true,
replaceFromOffset: prefixLength,
replaceToOffset: prefixLength,
});
cancelHydrationAutocompleteRef.current = true;
- richText.replacePlainTextRange(
+ replacePlainTextRange(
edit.replaceFromOffset,
edit.replaceToOffset,
edit.insertText,
@@ -90,9 +170,20 @@ export function usePersistentAgentMentionHydration({
cancelHydrationAutocompleteRef.current = false;
// Hydration is a programmatic transition, not an authored query. Cancel
// only when its editor updates actually scheduled autocomplete work.
- mentions.cancelMentionAutocomplete();
+ cancelMentionAutocomplete();
}
- }, [audience.enabled, audience.pubkeys, audienceScope, mentions, richText]);
+ }, [
+ audienceEnabled,
+ audiencePubkeys,
+ audienceScope,
+ cancelMentionAutocomplete,
+ extractMentionPubkeys,
+ getMentionDisplayName,
+ getPlainTextAndCursor,
+ insertResolvedMention,
+ registerMentionPubkey,
+ replacePlainTextRange,
+ ]);
const reconcile = React.useCallback(
(text: string) => {
@@ -103,12 +194,32 @@ export function usePersistentAgentMentionHydration({
isEditingRef.current
)
return;
- const present = new Set(mentions.extractMentionPubkeys(text));
- for (const pubkey of audienceRef.current.pubkeys) {
- if (!present.has(pubkey)) audienceRef.current.removePubkey(pubkey);
- }
+ cancelReconcile();
+ reconcileTimerRef.current = setTimeout(() => {
+ reconcileTimerRef.current = null;
+ if (
+ !hydratedRef.current ||
+ isRestoringRef.current ||
+ isSubmittingRef.current ||
+ isEditingRef.current ||
+ isMentionOpenRef.current
+ )
+ return;
+ const present = new Set(extractMentionPubkeys(text));
+ for (const pubkey of audienceRef.current.pubkeys) {
+ const hydratedLabel =
+ hydratedMentionLabelsRef.current.get(pubkey) ??
+ getMentionDisplayName(pubkey);
+ if (
+ !present.has(pubkey) &&
+ (!hydratedLabel || getMentionOffset(text, hydratedLabel) === null)
+ ) {
+ audienceRef.current.removePubkey(pubkey);
+ }
+ }
+ }, RECONCILE_DELAY_MS);
},
- [mentions.extractMentionPubkeys],
+ [cancelReconcile, extractMentionPubkeys, getMentionDisplayName],
);
const hydrateRef = React.useRef(hydrate);
@@ -117,35 +228,35 @@ export function usePersistentAgentMentionHydration({
(cancelAutocomplete = false) =>
requestAnimationFrame(() => {
hydrateRef.current();
- if (cancelAutocomplete) mentions.cancelMentionAutocomplete();
+ if (cancelAutocomplete) cancelMentionAutocomplete();
}),
- [mentions.cancelMentionAutocomplete],
+ [cancelMentionAutocomplete],
);
React.useEffect(() => {
void hydrationKey;
+ cancelReconcile();
hydratedRef.current = false;
+ hydratedMentionLabelsRef.current.clear();
const frame = scheduleHydration();
return () => cancelAnimationFrame(frame);
- }, [hydrationKey, scheduleHydration]);
+ }, [cancelReconcile, hydrationKey, scheduleHydration]);
const resolvePostSendContent = React.useCallback(
(explicitAgentPubkeys: string[]) => {
- if (!audience.enabled || !audienceScope || isEditingRef.current)
- return "";
+ if (!audienceEnabled || !audienceScope || isEditingRef.current) return "";
const orderedPubkeys = [
- ...new Set([...explicitAgentPubkeys, ...audience.pubkeys]),
+ ...new Set([...explicitAgentPubkeys, ...audiencePubkeys]),
];
- const targets = orderedPubkeys
- .map((pubkey) => ({
- pubkey,
- displayName: mentions.getMentionDisplayName(pubkey),
- }))
- .filter((target): target is { pubkey: string; displayName: string } =>
- Boolean(target.displayName),
- );
- mentions.clearMentions();
+ const targets = resolvePersistentMentionTargets(
+ orderedPubkeys,
+ getMentionDisplayName,
+ );
+ hydratedMentionLabelsRef.current = new Map(
+ targets.map((target) => [target.pubkey, target.displayName]),
+ );
+ clearMentions();
for (const target of targets) {
- mentions.registerMentionPubkey(target.displayName, target.pubkey, {
+ registerMentionPubkey(target.displayName, target.pubkey, {
isAgent: true,
});
}
@@ -156,19 +267,85 @@ export function usePersistentAgentMentionHydration({
(targets.length > 0 ? " " : "")
);
},
- [audience.enabled, audience.pubkeys, audienceScope, mentions],
+ [
+ audienceEnabled,
+ audiencePubkeys,
+ audienceScope,
+ clearMentions,
+ getMentionDisplayName,
+ registerMentionPubkey,
+ ],
+ );
+
+ const removeMentionToken = React.useCallback(
+ (pubkey: string) => {
+ const current = getPlainTextAndCursor().text;
+ const range = getPersistentMentionTokenRemovalRange(
+ current,
+ pubkey,
+ hydratedMentionLabelsRef.current,
+ getMentionDisplayName,
+ );
+ if (!range) return;
+ hydratedMentionLabelsRef.current.delete(pubkey);
+ replacePlainTextRange(range.from, range.to, "");
+ cancelMentionAutocomplete();
+ },
+ [
+ cancelMentionAutocomplete,
+ getMentionDisplayName,
+ getPlainTextAndCursor,
+ replacePlainTextRange,
+ ],
+ );
+
+ const beginSubmit = React.useCallback(() => {
+ cancelReconcile();
+ isSubmittingRef.current = true;
+ }, [cancelReconcile]);
+
+ const endSubmit = React.useCallback(() => {
+ isSubmittingRef.current = false;
+ scheduleHydration(true);
+ }, [scheduleHydration]);
+
+ const getAudienceRevision = React.useCallback(
+ () =>
+ scopeRef.current
+ ? getPersistentAgentAudienceRevision(scopeRef.current)
+ : null,
+ [],
+ );
+
+ const audienceChips = React.useMemo(
+ () =>
+ resolvePersistentMentionTargets(
+ audiencePubkeys,
+ (pubkey) => getMentionDisplayName(pubkey) ?? truncatePubkey(pubkey),
+ ),
+ [audiencePubkeys, getMentionDisplayName],
+ );
+
+ const removeAudienceMember = React.useCallback(
+ (pubkey: string) => {
+ removeMentionToken(pubkey);
+ audience.removePubkey(pubkey);
+ },
+ [audience.removePubkey, removeMentionToken],
);
return {
audience,
- beginSubmit: () => {
- isSubmittingRef.current = true;
- },
- endSubmit: () => {
- isSubmittingRef.current = false;
- scheduleHydration(true);
+ audienceChipsProps: {
+ audience: audienceChips,
+ onRemove: removeAudienceMember,
},
+ audienceChips,
+ beginSubmit,
+ endSubmit,
+ getAudienceRevision,
reconcile,
+ removeAudienceMember,
resolvePostSendContent,
scheduleHydration,
};
diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts
index ae424b4e5c..0aa4e8a08f 100644
--- a/desktop/tests/e2e/persistent-agent-audience.spec.ts
+++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts
@@ -260,6 +260,67 @@ test("persistent agents restore through the native inline mention UI", async ({
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
});
+test("audience chips remove and explicitly restore one persistent agent", async ({
+ page,
+}) => {
+ await seedAudience(page, [AGENT_A, AGENT_B]);
+ await installAudienceFixtures(page);
+ await openThread(page);
+
+ const composer = threadComposer(page);
+ const input = composer.getByTestId("message-input");
+ await expect(composer.getByTestId("composer-audience-chips")).toBeVisible();
+ await expect(
+ composer.getByTestId(`composer-audience-chip-${AGENT_A}`),
+ ).toContainText("Morgarita");
+ await expect(
+ composer.getByTestId(`composer-audience-chip-${AGENT_B}`),
+ ).toContainText("Vogue");
+
+ await composer
+ .getByTestId(`composer-audience-chip-remove-${AGENT_A}`)
+ .click();
+ await expect(
+ composer.getByTestId(`composer-audience-chip-${AGENT_A}`),
+ ).toHaveCount(0);
+ await expect(input).not.toContainText("@Morgarita");
+ await expect
+ .poll(() =>
+ page.evaluate(
+ (scope) =>
+ JSON.parse(
+ localStorage.getItem("buzz:persistent-agent-audiences:v2") ?? "{}",
+ )[scope] ?? [],
+ SCOPE,
+ ),
+ )
+ .toEqual([AGENT_B]);
+
+ await input.pressSequentially("@Mor");
+ await composer
+ .getByTestId("mention-autocomplete")
+ .getByText("Morgarita", { exact: true })
+ .click();
+ await input.pressSequentially(" please continue");
+ await composer.getByTestId("send-message").click();
+
+ await expect
+ .poll(() =>
+ page.evaluate(
+ (scope) =>
+ JSON.parse(
+ localStorage.getItem("buzz:persistent-agent-audiences:v2") ?? "{}",
+ )[scope] ?? [],
+ SCOPE,
+ ),
+ )
+ .toEqual([AGENT_A, AGENT_B]);
+ await expect(input).toContainText("@Morgarita");
+ await expect(input).toContainText("@Vogue");
+ await waitForAnimations(page);
+ await composer.screenshot({ path: `${SHOTS}/removable-audience-chips.png` });
+});
+
for (const theme of ["buzz", "buzz-dark"]) {
test(`captures native persistent mentions in ${theme}`, async ({ page }) => {
await seedAudience(page, [AGENT_A, AGENT_B], theme);