From a6eb63084698a8217d6c856598442ab795230252 Mon Sep 17 00:00:00 2001 From: Brian Charbonneau Date: Mon, 3 Aug 2026 23:49:40 -0700 Subject: [PATCH] feat(desktop): add removable persistent audience chips Signed-off-by: Brian Charbonneau --- .../messages/ui/ComposerAudienceChips.tsx | 42 +++ .../features/messages/ui/MessageComposer.tsx | 12 +- ...sePersistentAgentMentionHydration.test.mjs | 51 ++++ .../ui/usePersistentAgentMentionHydration.ts | 273 +++++++++++++++--- .../e2e/persistent-agent-audience.spec.ts | 61 ++++ 5 files changed, 385 insertions(+), 54 deletions(-) create mode 100644 desktop/src/features/messages/ui/ComposerAudienceChips.tsx create mode 100644 desktop/src/features/messages/ui/usePersistentAgentMentionHydration.test.mjs 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);