Persist agent audiences with native inline mentions (#1949)

Signed-off-by: npub1n4y9luxx9y27pz5qz93vr9w8auyk7mmpgwf9gpe9tn4zv4kyhzjqtcntu7 <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1n4y9luxx9y27pz5qz93vr9w8auyk7mmpgwf9gpe9tn4zv4kyhzjqtcntu7 <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
morgmart
2026-07-16 10:25:44 -07:00
committed by GitHub
co-authored by npub1n4y9luxx9y27pz5qz93vr9w8auyk7mmpgwf9gpe9tn4zv4kyhzjqtcntu7 npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex
parent b1ff68246f
commit 19dc33bda6
17 changed files with 986 additions and 25 deletions
+1
View File
@@ -54,6 +54,7 @@ export default defineConfig({
"**/composer-tooltip-dismiss.spec.ts",
"**/mentions.spec.ts",
"**/team-mentions.spec.ts",
"**/persistent-agent-audience.spec.ts",
"**/relay-reconnect.spec.ts",
"**/relay-reconnect-affordance.spec.ts",
"**/workflows.spec.ts",
+4 -1
View File
@@ -460,7 +460,10 @@ const overrides = new Map([
// +3: onLinkShortcutRef wiring (ref decl + editor option + assignment) for
// the ⌘K link-editor shortcut, mirroring the existing onEditLinkRef
// pattern. Queued to split with the rest of this list.
["src/features/messages/ui/MessageComposer.tsx", 1036],
// +35: persistent audience scope/hook wiring and chip component handoff. The
// chip markup lives separately; remaining lines connect existing composer
// send state to the audience store. Queued with the existing split.
["src/features/messages/ui/MessageComposer.tsx", 1091],
// global-agent-config: model-tuning section (BuzzAgentModelTuningFields via
// EditAgentAdvancedFields) + providerValid gate + effectiveProvider derivation
// + globalProvider threading into getPersonaProviderOptions. All load-bearing
@@ -754,6 +754,7 @@ export const ChannelPane = React.memo(function ChannelPane({
<WelcomeComposerBanner state={welcomeComposerBannerState} />
) : null}
<MessageComposer
audienceContext={{ type: "timeline" }}
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
channelType={activeChannel?.channelType ?? null}
@@ -472,6 +472,10 @@ export function InboxDetailPane({
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
<div className="pointer-events-auto">
<MessageComposer
audienceContext={{
type: "thread",
threadRootId: item.conversationId,
}}
channelId={item.item.channelId}
channelName={item.channelLabel ?? "channel"}
channelType={composerChannelType}
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import { filterEffectiveExplicitAgentPubkeys } from "./effectiveExplicitAgentPubkeys.ts";
const agentA = "a".repeat(64);
const agentB = "b".repeat(64);
const person = "c".repeat(64);
test("send without inviting excludes removed agents from audience promotion", () => {
assert.deepEqual(
filterEffectiveExplicitAgentPubkeys([agentA, agentB], [agentA, person]),
[agentA],
);
});
test("effective audience promotion keeps authored order and dedupes", () => {
assert.deepEqual(
filterEffectiveExplicitAgentPubkeys(
[agentB.toUpperCase(), agentA, agentB],
[agentA, agentB],
),
[agentB, agentA],
);
});
@@ -0,0 +1,19 @@
function normalizePubkey(pubkey: string): string {
return pubkey.trim().toLowerCase();
}
function uniqueNormalizedPubkeys(pubkeys: Iterable<string>): string[] {
return [...new Set([...pubkeys].map(normalizePubkey))].filter(Boolean);
}
export function filterEffectiveExplicitAgentPubkeys(
explicitAgentPubkeys: Iterable<string>,
effectiveMentionPubkeys: Iterable<string>,
): string[] {
const effectivePubkeys = new Set(
uniqueNormalizedPubkeys(effectiveMentionPubkeys),
);
return uniqueNormalizedPubkeys(explicitAgentPubkeys).filter((pubkey) =>
effectivePubkeys.has(pubkey),
);
}
@@ -0,0 +1,247 @@
import assert from "node:assert/strict";
import test from "node:test";
function createStorage() {
const values = new Map();
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
};
}
const agentA = "a".repeat(64);
const agentB = "b".repeat(64);
const agentC = "c".repeat(64);
const ownerA = "1".repeat(64);
const ownerB = "2".repeat(64);
const storageKey = "buzz:persistent-agent-audiences:v2";
let loadSequence = 0;
async function loadStore(offset = 0) {
globalThis.window = { localStorage: createStorage() };
loadSequence += 1;
return import(
`./persistentAgentAudience.ts?test=${Date.now()}-${offset}-${loadSequence}`
);
}
function savedAudiences() {
return JSON.parse(window.localStorage.getItem(storageKey));
}
test("conversation scopes isolate identities, channels, and threads", async () => {
const store = await loadStore();
const channelA = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "channel-a",
});
const channelB = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "channel-b",
});
const threadA1 = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "channel-a",
threadRootId: "root-1",
});
const threadA2 = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "channel-a",
threadRootId: "root-2",
});
const otherIdentity = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerB,
channelId: "channel-a",
});
for (const scope of [channelA, channelB, threadA1, threadA2, otherIdentity]) {
assert.ok(scope);
store.setPersistentAgentAudience(scope, [agentA]);
}
assert.equal(new Set(Object.keys(savedAudiences())).size, 5);
});
test("successful fast send promotes without a persisted draft key", async () => {
const store = await loadStore(1);
const scope = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "channel-a",
});
store.setPersistentAgentAudienceEnabled(true);
store.promotePersistentAgentAudience({
expectedGeneration: store.getPersistentAgentAudienceGeneration(),
scope,
expectedRevision: store.getPersistentAgentAudienceRevision(scope),
explicitAgentPubkeys: [agentA],
});
assert.deepEqual(savedAudiences(), { [scope]: [agentA] });
});
test("explicit recipients merge and dedupe after successful send", async () => {
const store = await loadStore(2);
const scope = `${ownerA}:channel-a:timeline`;
store.setPersistentAgentAudienceEnabled(true);
store.setPersistentAgentAudience(scope, [agentA]);
const revision = store.getPersistentAgentAudienceRevision(scope);
store.promotePersistentAgentAudience({
expectedGeneration: store.getPersistentAgentAudienceGeneration(),
scope,
expectedRevision: revision,
explicitAgentPubkeys: [agentA, agentB],
});
assert.deepEqual(savedAudiences(), { [scope]: [agentA, agentB] });
});
test("successful send makes authored mention order authoritative", async () => {
const store = await loadStore(100);
const scope = `${ownerA}:channel-a:timeline`;
store.setPersistentAgentAudienceEnabled(true);
store.setPersistentAgentAudience(scope, [agentA, agentB]);
store.promotePersistentAgentAudience({
expectedGeneration: store.getPersistentAgentAudienceGeneration(),
scope,
expectedRevision: store.getPersistentAgentAudienceRevision(scope),
explicitAgentPubkeys: [agentB, agentA, agentC],
});
assert.deepEqual(savedAudiences(), {
[scope]: [agentB, agentA, agentC],
});
});
test("successful send retains saved targets absent from the draft", async () => {
const store = await loadStore(101);
const scope = `${ownerA}:channel-a:timeline`;
store.setPersistentAgentAudienceEnabled(true);
store.setPersistentAgentAudience(scope, [agentA, agentC]);
store.promotePersistentAgentAudience({
expectedGeneration: store.getPersistentAgentAudienceGeneration(),
scope,
expectedRevision: store.getPersistentAgentAudienceRevision(scope),
explicitAgentPubkeys: [agentB, agentA],
});
assert.deepEqual(savedAudiences(), {
[scope]: [agentB, agentA, agentC],
});
});
test("removal while send awaits wins over late success", async () => {
const store = await loadStore(3);
const scope = `${ownerA}:channel-a:timeline`;
store.setPersistentAgentAudienceEnabled(true);
store.setPersistentAgentAudience(scope, [agentA]);
const revisionAtSubmit = store.getPersistentAgentAudienceRevision(scope);
store.removePersistentAgentAudienceMember(scope, agentA);
store.promotePersistentAgentAudience({
expectedGeneration: store.getPersistentAgentAudienceGeneration(),
scope,
expectedRevision: revisionAtSubmit,
explicitAgentPubkeys: [agentA],
});
assert.deepEqual(savedAudiences(), { [scope]: [] });
});
test("removing final chip preserves an explicit empty scope", async () => {
const store = await loadStore(4);
const scope = `${ownerA}:channel-a:thread:root`;
store.setPersistentAgentAudience(scope, [agentA]);
store.removePersistentAgentAudienceMember(scope, agentA);
assert.deepEqual(savedAudiences(), { [scope]: [] });
});
test("completion after disabling cannot repopulate audiences", async () => {
const store = await loadStore(5);
const scope = `${ownerA}:channel-a:timeline`;
store.setPersistentAgentAudienceEnabled(true);
store.setPersistentAgentAudience(scope, [agentA]);
const revisionAtSubmit = store.getPersistentAgentAudienceRevision(scope);
store.setPersistentAgentAudienceEnabled(false);
store.promotePersistentAgentAudience({
expectedGeneration: store.getPersistentAgentAudienceGeneration(),
scope,
expectedRevision: revisionAtSubmit,
explicitAgentPubkeys: [agentB],
});
assert.deepEqual(savedAudiences(), {});
});
test("invalid, duplicate, and differently-cased pubkeys normalize", async () => {
const store = await loadStore(6);
const scope = `${ownerA}:channel-a:timeline`;
store.setPersistentAgentAudience(scope, [
agentA.toUpperCase(),
agentA,
"bad",
]);
assert.deepEqual(savedAudiences(), { [scope]: [agentA] });
});
test("new recipients retain explicit mention order", async () => {
const store = await loadStore(9);
const scope = `${ownerA}:channel-a:timeline`;
store.setPersistentAgentAudienceEnabled(true);
store.promotePersistentAgentAudience({
expectedGeneration: store.getPersistentAgentAudienceGeneration(),
scope,
expectedRevision: store.getPersistentAgentAudienceRevision(scope),
explicitAgentPubkeys: [agentB, agentA],
});
assert.deepEqual(savedAudiences(), { [scope]: [agentB, agentA] });
});
test("first new-message send resolves its destination after capturing generation", async () => {
const store = await loadStore(7);
const capturedGeneration = store.getPersistentAgentAudienceGeneration();
store.setPersistentAgentAudienceEnabled(true);
const scope = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "resolved-dm",
});
store.promotePersistentAgentAudience({
expectedGeneration: capturedGeneration,
expectedRevision: null,
scope,
explicitAgentPubkeys: [agentA],
});
assert.deepEqual(savedAudiences(), { [scope]: [agentA] });
});
test("disable during new-message destination preparation invalidates promotion", async () => {
const store = await loadStore(8);
store.setPersistentAgentAudienceEnabled(true);
const capturedGeneration = store.getPersistentAgentAudienceGeneration();
store.setPersistentAgentAudienceEnabled(false);
store.setPersistentAgentAudienceEnabled(true);
const scope = store.getPersistentAgentAudienceScope({
ownerPubkey: ownerA,
channelId: "resolved-dm",
});
store.promotePersistentAgentAudience({
expectedGeneration: capturedGeneration,
expectedRevision: null,
scope,
explicitAgentPubkeys: [agentA],
});
assert.deepEqual(savedAudiences(), {});
});
@@ -0,0 +1,236 @@
import * as React from "react";
const ENABLED_STORAGE_KEY = "buzz:keep-addressed-agents-active";
const AUDIENCES_STORAGE_KEY = "buzz:persistent-agent-audiences:v2";
const listeners = new Set<() => void>();
const revisions = new Map<string, number>();
let revisionClock = 0;
let defaultRevision = 0;
let generation = 0;
let enabled = readEnabled();
let audiences = readAudiences();
let snapshot = buildSnapshot();
export type PersistentAgentAudienceSnapshot = Readonly<{
enabled: boolean;
audiences: Readonly<Record<string, readonly string[]>>;
generation: number;
}>;
type PersistentAgentAudienceScopeInput = {
ownerPubkey: string;
channelId: string;
threadRootId?: string | null;
};
function normalizePubkeys(pubkeys: Iterable<string>): string[] {
return [
...new Set([...pubkeys].map((pubkey) => pubkey.trim().toLowerCase())),
].filter((pubkey) => /^[0-9a-f]{64}$/.test(pubkey));
}
function readEnabled(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(ENABLED_STORAGE_KEY) === "1";
} catch {
return false;
}
}
function readAudiences(): Record<string, string[]> {
if (typeof window === "undefined") return {};
try {
const parsed: unknown = JSON.parse(
window.localStorage.getItem(AUDIENCES_STORAGE_KEY) ?? "{}",
);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
return {};
const result: Record<string, string[]> = {};
for (const [scope, value] of Object.entries(parsed)) {
if (scope && Array.isArray(value)) {
result[scope] = normalizePubkeys(
value.filter((entry): entry is string => typeof entry === "string"),
);
}
}
return result;
} catch {
return {};
}
}
function buildSnapshot(): PersistentAgentAudienceSnapshot {
return { enabled, audiences, generation };
}
function emit(): void {
snapshot = buildSnapshot();
for (const listener of listeners) listener();
}
function persistAudiences(): void {
try {
window.localStorage.setItem(
AUDIENCES_STORAGE_KEY,
JSON.stringify(audiences),
);
} catch {
// Persistence is best-effort; the live session still uses in-memory state.
}
}
function advanceRevision(scope: string): void {
revisionClock += 1;
revisions.set(scope, revisionClock);
}
export function setPersistentAgentAudienceEnabled(nextEnabled: boolean): void {
if (enabled === nextEnabled) return;
enabled = nextEnabled;
if (!nextEnabled) {
generation += 1;
revisionClock += 1;
defaultRevision = revisionClock;
revisions.clear();
audiences = {};
persistAudiences();
}
try {
window.localStorage.setItem(ENABLED_STORAGE_KEY, nextEnabled ? "1" : "0");
} catch {
// Persistence is best-effort.
}
emit();
}
export function getPersistentAgentAudienceScope({
ownerPubkey,
channelId,
threadRootId = null,
}: PersistentAgentAudienceScopeInput): string | null {
const owner = ownerPubkey.trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(owner) || !channelId) return null;
const conversation = threadRootId ? `thread:${threadRootId}` : "timeline";
return `${owner}:${channelId}:${conversation}`;
}
export function getPersistentAgentAudienceGeneration(): number {
return generation;
}
export function getPersistentAgentAudienceRevision(scope: string): number {
return revisions.get(scope) ?? defaultRevision;
}
export function setPersistentAgentAudience(
scope: string,
pubkeys: Iterable<string>,
): void {
if (!scope) return;
const normalized = normalizePubkeys(pubkeys);
const current = audiences[scope];
if (
current !== undefined &&
current.length === normalized.length &&
current.every((pubkey, index) => pubkey === normalized[index])
) {
return;
}
audiences = { ...audiences, [scope]: normalized };
advanceRevision(scope);
persistAudiences();
emit();
}
export function promotePersistentAgentAudience({
expectedGeneration,
expectedRevision,
explicitAgentPubkeys,
scope,
}: {
expectedGeneration: number;
expectedRevision: number | null;
explicitAgentPubkeys: string[];
scope: string | null;
}): void {
if (
!enabled ||
expectedGeneration !== generation ||
!scope ||
(expectedRevision !== null &&
getPersistentAgentAudienceRevision(scope) !== expectedRevision)
) {
return;
}
setPersistentAgentAudience(scope, [
...explicitAgentPubkeys,
...(audiences[scope] ?? []),
]);
}
export function removePersistentAgentAudienceMember(
scope: string,
pubkey: string,
): void {
setPersistentAgentAudience(
scope,
(audiences[scope] ?? []).filter(
(candidate) => candidate !== pubkey.trim().toLowerCase(),
),
);
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): PersistentAgentAudienceSnapshot {
return snapshot;
}
const serverSnapshot: PersistentAgentAudienceSnapshot = {
enabled: false,
audiences: {},
generation: 0,
};
export function usePersistentAgentAudience(scope: string | null): {
enabled: boolean;
pubkeys: readonly string[];
generation: number;
revision: number;
setEnabled: (enabled: boolean) => void;
promotePubkeys: typeof promotePersistentAgentAudience;
removePubkey: (pubkey: string) => void;
clear: () => void;
} {
const state = React.useSyncExternalStore(
subscribe,
getSnapshot,
() => serverSnapshot,
);
const resolvedScope = scope ?? "";
return {
enabled: state.enabled,
pubkeys: resolvedScope ? (state.audiences[resolvedScope] ?? []) : [],
generation: state.generation,
revision: resolvedScope
? getPersistentAgentAudienceRevision(resolvedScope)
: 0,
setEnabled: setPersistentAgentAudienceEnabled,
promotePubkeys: promotePersistentAgentAudience,
removePubkey: React.useCallback(
(pubkey) => removePersistentAgentAudienceMember(resolvedScope, pubkey),
[resolvedScope],
),
clear: React.useCallback(
() => setPersistentAgentAudience(resolvedScope, []),
[resolvedScope],
),
};
}
@@ -74,6 +74,14 @@ function formatSearchUserSecondaryLabel(user: UserSearchResult) {
return null;
}
function appendUniqueName(current: string[], name: string): string[] {
return current.some(
(candidate) => candidate.toLowerCase() === name.toLowerCase(),
)
? current
: [...current, name];
}
export function useMentions(
channelId: string | null,
externalMembers?: ChannelMember[],
@@ -694,35 +702,43 @@ export function useMentions(
personaMentionMapRef.current.delete(trimmedName);
trimMapToSize(mentionMapRef.current, 200);
setSelectedMentionNames((current) => {
if (
current.some(
(name) => name.toLowerCase() === trimmedName.toLowerCase(),
)
) {
return current;
}
return [...current, trimmedName];
});
setSelectedMentionNames((current) =>
appendUniqueName(current, trimmedName),
);
if (options?.isAgent) {
setSelectedAgentMentionNames((current) => {
if (
current.some(
(name) => name.toLowerCase() === trimmedName.toLowerCase(),
)
) {
return current;
}
return [...current, trimmedName];
});
setSelectedAgentMentionNames((current) =>
appendUniqueName(current, trimmedName),
);
}
},
[],
);
const insertResolvedMention = React.useCallback(
({
displayName,
pubkey,
replaceFromOffset,
replaceToOffset,
isAgent = false,
}: {
displayName: string;
pubkey: string;
replaceFromOffset: number;
replaceToOffset: number;
isAgent?: boolean;
}): AutocompleteEdit => {
registerMentionPubkey(displayName, pubkey, { isAgent });
return {
replaceFromOffset,
replaceToOffset,
insertText: `@${displayName.trim()} `,
};
},
[registerMentionPubkey],
);
const getMentionDisplayName = React.useCallback(
(pubkey: string): string | null => {
const normalizedPubkey = normalizePubkey(pubkey);
@@ -956,6 +972,7 @@ export function useMentions(
handleMentionKeyDown,
hasResolvedMembers: members !== undefined,
insertMention,
insertResolvedMention,
agentKnownNames: agentHighlightNames,
isAgentPubkey,
isManagedAgentPubkey,
@@ -26,6 +26,8 @@ import {
useMediaUpload,
} from "@/features/messages/lib/useMediaUpload";
import { useMentions } from "@/features/messages/lib/useMentions";
import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import {
hasMentionClipboardHtml,
@@ -54,10 +56,16 @@ import {
import { MessageComposerToolbar } from "./MessageComposerToolbar";
import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
import { useMentionSendFlow } from "./useMentionSendFlow";
import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration";
import { useComposerContentState } from "./useComposerContentState";
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
type MessageComposerAudienceContext =
| { type: "timeline" }
| { type: "thread"; threadRootId: string };
type MessageComposerProps = {
audienceContext?: MessageComposerAudienceContext | null;
channelId?: string | null;
channelName: string;
channelType?: ChannelType | null;
@@ -137,6 +145,7 @@ type MessageComposerProps = {
};
function MessageComposerImpl({
audienceContext = null,
channelId = null,
channelName,
channelType = null,
@@ -186,7 +195,19 @@ function MessageComposerImpl({
}, []);
const drafts = useDrafts();
const identityQuery = useIdentityQuery();
const effectiveDraftKey = draftKey ?? channelId;
const ownerPubkey = identityQuery.data?.pubkey ?? null;
const audienceThreadRootId =
audienceContext?.type === "thread" ? audienceContext.threadRootId : null;
const audienceScope =
audienceContext && channelId && ownerPubkey
? getPersistentAgentAudienceScope({
ownerPubkey,
channelId,
threadRootId: audienceThreadRootId,
})
: null;
const effectiveDraftKeyRef = React.useRef(effectiveDraftKey);
effectiveDraftKeyRef.current = effectiveDraftKey;
// Snapshot composer state before edit mode so cancel can restore it.
@@ -321,6 +342,8 @@ function MessageComposerImpl({
channelLinks.updateChannelQuery(text, cursor);
emojiAutocomplete.updateEmojiQuery(text, cursor);
persistentMentionHydrationRef.current?.reconcile(text);
if (text.trim().length > 0) {
notifyTyping();
}
@@ -338,6 +361,19 @@ function MessageComposerImpl({
onLinkShortcutRef.current = linkEditor.openFromShortcut;
useComposerSpoilerParticles(richText.editor, composerScrollRef);
const persistentMentionHydration = usePersistentAgentMentionHydration({
audienceScope,
hydrationKey: effectiveDraftKey,
isEditing: editTarget != null,
mentions,
richText,
});
const persistentAudience = persistentMentionHydration.audience;
const persistentMentionHydrationRef = React.useRef(
persistentMentionHydration,
);
persistentMentionHydrationRef.current = persistentMentionHydration;
const mentionSendFlow = useMentionSendFlow({
channelId,
channelLinks,
@@ -354,6 +390,17 @@ function MessageComposerImpl({
setIsEmojiPickerOpen,
setPendingImeta: media.setPendingImeta,
setSpoileredAttachmentUrls,
onSuccessfulExplicitAgentAudience:
persistentAudience.enabled && audienceContext && ownerPubkey
? ({ channelId: successfulChannelId, ...promotion }) => {
const scope = getPersistentAgentAudienceScope({
ownerPubkey,
channelId: successfulChannelId,
threadRootId: audienceThreadRootId,
});
persistentAudience.promotePubkeys({ ...promotion, scope });
}
: undefined,
});
// biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger
@@ -617,6 +664,7 @@ function MessageComposerImpl({
}
onPreparingMentionSendChange?.(true);
persistentMentionHydration.beginSubmit();
try {
await mentionSendFlow.sendMessageWithMentionFlow({
capturedChannelId: channelId,
@@ -628,8 +676,11 @@ function MessageComposerImpl({
),
spoileredAttachmentUrls,
trimmed,
audienceGeneration: persistentAudience.generation,
audienceRevision: audienceScope ? persistentAudience.revision : null,
});
} finally {
persistentMentionHydration.endSubmit();
onPreparingMentionSendChange?.(false);
}
}, [
@@ -650,6 +701,10 @@ function MessageComposerImpl({
syncComposerContentFromEditor,
onCaptureSendContext,
onPreparingMentionSendChange,
audienceScope,
persistentMentionHydration,
persistentAudience.generation,
persistentAudience.revision,
]);
submitMessageRef.current = submitMessage;
@@ -881,6 +881,10 @@ export function MessageThreadPanel({
>
<div className="pointer-events-auto">
<MessageComposer
audienceContext={{
type: "thread",
threadRootId: threadHead.id,
}}
channelId={channelId}
channelName={channelName}
channelType={channel?.channelType ?? null}
@@ -594,6 +594,7 @@ export function NewMessageScreen() {
) : null}
<MessageComposer
audienceContext={{ type: "timeline" }}
channelName="new message"
channelType="dm"
containerClassName="px-5"
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
async function source(relativePath) {
return readFile(new URL(relativePath, import.meta.url), "utf8");
}
test("supported conversation hosts opt into explicit audience contexts", async () => {
const [channelPane, threadPanel, newMessage, inboxDetail] = await Promise.all(
[
source("../../channels/ui/ChannelPane.tsx"),
source("./MessageThreadPanel.tsx"),
source("./NewMessageScreen.tsx"),
source("../../home/ui/InboxDetailPane.tsx"),
],
);
assert.match(channelPane, /audienceContext=\{\{ type: "timeline" \}\}/);
assert.match(newMessage, /audienceContext=\{\{ type: "timeline" \}\}/);
assert.match(
threadPanel,
/type: "thread"[\s\S]*threadRootId: threadHead\.id/,
);
assert.match(
inboxDetail,
/type: "thread"[\s\S]*threadRootId: item\.conversationId/,
);
});
test("video review remains explicitly outside persistent audience routing", async () => {
const videoPlayer = await source("../../../shared/ui/VideoPlayer.tsx");
const composer = videoPlayer.slice(videoPlayer.indexOf("<MessageComposer"));
assert.match(composer, /draftKey=/);
assert.doesNotMatch(
composer.slice(0, composer.indexOf("/>") + 2),
/audienceContext=/,
);
});
test("composer never derives audience context from draft keys", async () => {
const composer = await source("./MessageComposer.tsx");
assert.doesNotMatch(composer, /draftKey\?\.startsWith\("thread:"\)/);
assert.match(composer, /audienceContext\?\.type === "thread"/);
});
@@ -12,6 +12,7 @@ import {
} from "@/features/agents/hooks";
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
import { useAddChannelMembersMutation } from "@/features/channels/hooks";
import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys";
import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks";
import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete";
import {
@@ -45,6 +46,10 @@ type PendingNonMemberMentionSend = {
savedImeta: ImetaMedia[];
savedSpoileredAttachmentUrls: Set<string>;
sentDraftKey: string | null | undefined;
audienceGeneration: number;
audienceRevision: number | null;
/** Agent mentions explicitly authored in this draft (never inferred). */
explicitAgentPubkeys: string[];
};
type SendMessageWithMentionFlowInput = {
@@ -58,6 +63,8 @@ type SendMessageWithMentionFlowInput = {
sentDraftKey: string | null | undefined;
spoileredAttachmentUrls?: ReadonlySet<string>;
trimmed: string;
audienceGeneration?: number;
audienceRevision?: number | null;
};
type UseMentionSendFlowOptions = {
@@ -91,6 +98,12 @@ type UseMentionSendFlowOptions = {
setSpoileredAttachmentUrls?: React.Dispatch<
React.SetStateAction<Set<string>>
>;
onSuccessfulExplicitAgentAudience?: (audience: {
channelId: string;
expectedGeneration: number;
expectedRevision: number | null;
explicitAgentPubkeys: string[];
}) => void;
};
function mergeOutgoingTagsWithReferenceMentions(
@@ -145,6 +158,7 @@ export function useMentionSendFlow({
setIsEmojiPickerOpen,
setPendingImeta,
setSpoileredAttachmentUrls,
onSuccessfulExplicitAgentAudience,
}: UseMentionSendFlowOptions) {
const [pendingNonMemberSend, setPendingNonMemberSend] =
React.useState<PendingNonMemberMentionSend | null>(null);
@@ -487,6 +501,22 @@ export function useMentionSendFlow({
sendChannelId,
draft.capturedThreadContext,
);
const effectiveExplicitAgentPubkeys =
filterEffectiveExplicitAgentPubkeys(
draft.explicitAgentPubkeys,
mentionPubkeys,
);
if (effectiveExplicitAgentPubkeys.length > 0) {
// Promote only explicitly authored agents that remained effective
// for this successful send. "Send without inviting" removes its
// excluded recipients here as well as from event routing.
onSuccessfulExplicitAgentAudience?.({
channelId: sendChannelId ?? draft.capturedChannelId ?? "",
expectedGeneration: draft.audienceGeneration,
expectedRevision: draft.audienceRevision,
explicitAgentPubkeys: effectiveExplicitAgentPubkeys,
});
}
if (draft.sentDraftKey) {
drafts.markDraftSent(
draft.sentDraftKey,
@@ -525,6 +555,7 @@ export function useMentionSendFlow({
mentions.isAgentPubkey,
onPrepareSendChannel,
onSendRef,
onSuccessfulExplicitAgentAudience,
richText.setContent,
setContent,
setPendingImeta,
@@ -597,6 +628,8 @@ export function useMentionSendFlow({
sentDraftKey,
spoileredAttachmentUrls = new Set(),
trimmed,
audienceGeneration = 0,
audienceRevision = null,
}: SendMessageWithMentionFlowInput) => {
if (isMentionSendPendingRef.current) {
return;
@@ -643,10 +676,16 @@ export function useMentionSendFlow({
const createdPersonaAgentPubkeySet = new Set(
createdPersonaAgentPubkeys.map(normalizePubkey),
);
const pubkeys = uniqueNormalizedPubkeys([
const explicitMentionPubkeys = uniqueNormalizedPubkeys([
...mentions.extractMentionPubkeys(trimmed),
...createdPersonaAgentPubkeys,
]);
const explicitAgentPubkeys = explicitMentionPubkeys.filter(
(pubkey) =>
mentions.isAgentPubkey(pubkey) ||
createdPersonaAgentPubkeySet.has(pubkey),
);
const pubkeys = explicitMentionPubkeys;
const { content: finalContent, mediaTags } = buildOutgoingMessage(
trimmed,
pendingImeta,
@@ -691,6 +730,9 @@ export function useMentionSendFlow({
savedImeta: [...pendingImeta],
savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls),
sentDraftKey,
audienceGeneration,
audienceRevision,
explicitAgentPubkeys,
};
if (promptNonMemberPubkeys.length > 0) {
@@ -714,6 +756,7 @@ export function useMentionSendFlow({
getNonMemberMentionPubkeys,
getDmThreadAgentMentionError,
mentions.extractMentionPubkeys,
mentions.isAgentPubkey,
mentions.isManagedAgentPubkey,
onPrepareSendChannel,
],
@@ -0,0 +1,123 @@
import * as React from "react";
import { usePersistentAgentAudience } from "@/features/messages/lib/persistentAgentAudience";
import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor";
export function usePersistentAgentMentionHydration({
audienceScope,
hydrationKey,
isEditing,
mentions,
richText,
}: {
audienceScope: string | null;
hydrationKey: string | null | undefined;
isEditing: boolean;
mentions: UseMentionsResult;
richText: UseRichTextEditorResult;
}) {
const audience = usePersistentAgentAudience(audienceScope);
const audienceRef = React.useRef(audience);
audienceRef.current = audience;
const scopeRef = React.useRef(audienceScope);
scopeRef.current = audienceScope;
const isEditingRef = React.useRef(isEditing);
isEditingRef.current = isEditing;
const isRestoringRef = React.useRef(false);
const isSubmittingRef = React.useRef(false);
const hydratedRef = React.useRef(false);
const hydrate = React.useCallback(() => {
const capturedScope = audienceScope;
if (
!audience.enabled ||
!capturedScope ||
isEditingRef.current ||
audience.pubkeys.length === 0
) {
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),
);
for (const target of targets)
mentions.registerMentionPubkey(target.displayName, target.pubkey, {
isAgent: true,
});
if (scopeRef.current !== capturedScope) {
isRestoringRef.current = false;
return;
}
const present = new Set(mentions.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({
...target,
isAgent: true,
replaceFromOffset: prefixLength,
replaceToOffset: prefixLength,
});
richText.replacePlainTextRange(
edit.replaceFromOffset,
edit.replaceToOffset,
edit.insertText,
);
prefixLength += edit.insertText.length;
}
hydratedRef.current = scopeRef.current === capturedScope;
isRestoringRef.current = false;
}, [audience.enabled, audience.pubkeys, audienceScope, mentions, richText]);
const reconcile = React.useCallback(
(text: string) => {
if (
!hydratedRef.current ||
isRestoringRef.current ||
isSubmittingRef.current ||
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);
}
},
[mentions.extractMentionPubkeys],
);
const scheduleHydration = React.useCallback(
() => requestAnimationFrame(hydrate),
[hydrate],
);
React.useEffect(() => {
void hydrationKey;
hydratedRef.current = false;
const frame = scheduleHydration();
return () => cancelAnimationFrame(frame);
}, [hydrationKey, scheduleHydration]);
return {
audience,
beginSubmit: () => {
isSubmittingRef.current = true;
},
endSubmit: () => {
isSubmittingRef.current = false;
scheduleHydration();
},
reconcile,
scheduleHydration,
};
}
@@ -1,20 +1,46 @@
import { usePreventSleepContext } from "@/features/agents/usePreventSleep";
import { Switch } from "@/shared/ui/switch";
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
import {
setPersistentAgentAudienceEnabled,
usePersistentAgentAudience,
} from "@/features/messages/lib/persistentAgentAudience";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
export function PreventSleepSettingsCard() {
const { enabled, setEnabled, hasRunningAgents, expired, clearExpired } =
usePreventSleepContext();
const persistentAudience = usePersistentAgentAudience(null);
return (
<section className="min-w-0" data-testid="settings-agents">
<SettingsSectionHeader
title="Agents"
description="Settings that affect how local managed agents run on this machine."
description="Control how agents behave in conversations and run on this machine."
/>
<SettingsOptionGroup>
<SettingsOptionRow>
<div className="min-w-0">
<label
className="text-sm font-medium"
htmlFor="persistent-agent-audience-switch"
>
Keep addressed agents active
</label>
<p className="text-sm font-normal text-muted-foreground">
Keep agents you address selected for future messages in the same
channel or thread. Remove them from the composer at any time.
</p>
</div>
<Switch
checked={persistentAudience.enabled}
data-testid="persistent-agent-audience-toggle"
id="persistent-agent-audience-switch"
onCheckedChange={setPersistentAgentAudienceEnabled}
/>
</SettingsOptionRow>
<SettingsOptionRow>
<div className="min-w-0">
<label
@@ -0,0 +1,109 @@
import { expect, test, type Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/persistent-agent-audience";
const OWNER = "deadbeef".repeat(8);
const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const AGENT_A = "a".repeat(64);
const AGENT_B = "b".repeat(64);
const SCOPE = `${OWNER}:${CHANNEL_ID}:timeline`;
async function seedAudience(page: Page, pubkeys: string[], theme = "buzz") {
await page.addInitScript(
({ audience, scope, selectedTheme }) => {
window.localStorage.setItem("buzz:keep-addressed-agents-active", "1");
window.localStorage.setItem(
"buzz:persistent-agent-audiences:v2",
JSON.stringify({ [scope]: audience }),
);
window.localStorage.setItem("buzz-theme", selectedTheme);
},
{ audience: pubkeys, scope: SCOPE, selectedTheme: theme },
);
}
async function openGeneral(page: Page) {
await page.goto(`/#/channels/${CHANNEL_ID}`, {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("chat-title")).toHaveText("general");
}
async function installAudienceFixtures(page: Page) {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_A,
name: "Morgarita",
status: "running",
channelNames: ["general"],
},
{
pubkey: AGENT_B,
name: "Vogue",
status: "running",
channelNames: ["general"],
},
],
});
}
test("persistent agents restore through the native inline mention UI", async ({
page,
}) => {
await seedAudience(page, [AGENT_B, AGENT_A]);
await installAudienceFixtures(page);
await openGeneral(page);
const input = page.getByTestId("message-input");
await expect(input).toHaveText("@Vogue @Morgarita ");
await expect(page.getByText("Talking to", { exact: true })).toHaveCount(0);
await expect(input.locator(".agent-mention-highlight")).toHaveCount(2);
await input.fill("@Morgarita hello");
await expect
.poll(() =>
page.evaluate(
({ scope }) => {
const stored = JSON.parse(
localStorage.getItem("buzz:persistent-agent-audiences:v2") ?? "{}",
);
return stored[scope] ?? [];
},
{ scope: SCOPE },
),
)
.toEqual([AGENT_A]);
await page.getByTestId("send-message").click();
await expect(input).toContainText("@Morgarita");
await expect(input).not.toContainText("@Vogue");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
});
for (const theme of ["buzz", "buzz-dark"]) {
test(`captures native persistent mentions in ${theme}`, async ({ page }) => {
await seedAudience(page, [AGENT_A, AGENT_B], theme);
await installAudienceFixtures(page);
await openGeneral(page);
const composer = page.getByTestId("message-composer");
await page.getByTestId("message-input").focus();
await waitForAnimations(page);
await composer.screenshot({
path: `${SHOTS}/${theme}-native-mentions.png`,
});
});
}
test("native persistent mentions fit the narrow composer", async ({ page }) => {
await page.setViewportSize({ width: 700, height: 760 });
await seedAudience(page, [AGENT_A, AGENT_B]);
await installAudienceFixtures(page);
await openGeneral(page);
const composer = page.getByTestId("message-composer");
await expect(page.getByTestId("message-input")).toContainText("@Morgarita");
await waitForAnimations(page);
await composer.screenshot({ path: `${SHOTS}/narrow-native-mentions.png` });
});