diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts
index ac3e40d57d4..5704988e306 100644
--- a/desktop/src/features/messages/ui/MessageComposer.types.ts
+++ b/desktop/src/features/messages/ui/MessageComposer.types.ts
@@ -25,7 +25,8 @@ export type MessageComposerEditTarget = {
export type MessageComposerProps = {
audienceContext?: {
- type: "channel" | "thread";
+ rootTags?: readonly string[][];
+ type: "thread";
} | null;
channelId?: string | null;
channelName: string;
diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
index 16ee26bcc79..1de7b140fdb 100644
--- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx
+++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
@@ -830,7 +830,10 @@ export function MessageThreadPanel({
>
{
+test("only thread conversation hosts opt into persistent audiences", async () => {
const [channelPane, threadPanel, newMessage, inboxDetail] = await Promise.all(
[
source("../../channels/ui/ChannelPane.tsx"),
@@ -16,9 +16,12 @@ test("supported conversation hosts opt into explicit audience contexts", async (
],
);
- assert.match(channelPane, /audienceContext=\{\{ type: "channel" \}\}/);
+ assert.doesNotMatch(channelPane, /audienceContext=/);
assert.doesNotMatch(newMessage, /audienceContext=/);
- assert.match(threadPanel, /audienceContext=\{\{ type: "thread" \}\}/);
+ assert.match(
+ threadPanel,
+ /audienceContext=\{\{[\s\S]*type: "thread",[\s\S]*rootTags: threadHead\.tags,[\s\S]*\}\}/,
+ );
assert.match(inboxDetail, /type: "thread"/);
assert.doesNotMatch(threadPanel, /audienceContext=\{[\s\S]*threadRootId/);
assert.doesNotMatch(inboxDetail, /audienceContext=\{[\s\S]*threadRootId/);
diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs
index 8ed155234fa..60ead286164 100644
--- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs
+++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs
@@ -140,7 +140,7 @@ test("always addressing a new agent delegates the first add for immediate confir
assert.deepEqual(pulsedPubkeys, []);
});
-test("toggling an addressed agent keeps autocomplete open and removes the lock", async () => {
+test("unpinning an addressed agent keeps its current mention and autocomplete open", async () => {
const { act, renderHook } = await import("@testing-library/react");
const { useAgentAddressLockPicker } = await import(
"./useAgentAddressLockPicker.ts"
@@ -187,20 +187,17 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock",
);
act(() => {
- result.current.toggleAlwaysAddressAgent({
- pubkey: "agent-pubkey",
- displayName: "Agent Ada",
- isAgent: true,
- });
+ result.current.toggleAlwaysAddressAgent(
+ {
+ pubkey: "agent-pubkey",
+ displayName: "Agent Ada",
+ isAgent: true,
+ },
+ { preserveMention: true },
+ );
});
- assert.deepEqual(appliedEdits, [
- {
- replaceFromOffset: 4,
- replaceToOffset: 15,
- insertText: "",
- },
- ]);
+ assert.deepEqual(appliedEdits, []);
assert.equal(cancelCount, 0);
assert.deepEqual(removedPubkeys, ["agent-pubkey"]);
assert.deepEqual(pulsedPubkeys, []);
diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts
index 4a9ca5f15c1..4dfe40b14b0 100644
--- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts
+++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts
@@ -14,45 +14,6 @@ import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import type { ComposerAddressAgent } from "./ComposerAddressControls";
import type { MentionSuggestion } from "./MentionAutocomplete";
-function buildMentionRemovalEdits(
- text: string,
- displayNames: readonly string[],
- queryRange?: { start: number; end: number },
-): AutocompleteEdit[] {
- const ranges = displayNames.flatMap((displayName) =>
- getMentionOffsets(text, displayName).map((start) => {
- let end = start + `@${displayName}`.length;
- if (text[end] === " ") end += 1;
- return { start, end };
- }),
- );
- if (queryRange) {
- ranges.push({
- start: Math.max(0, Math.min(queryRange.start, text.length)),
- end: Math.max(0, Math.min(queryRange.end, text.length)),
- });
- }
-
- const merged = ranges
- .filter(({ start, end }) => start < end)
- .sort((left, right) => left.start - right.start)
- .reduce>((result, range) => {
- const previous = result.at(-1);
- if (previous && range.start <= previous.end) {
- previous.end = Math.max(previous.end, range.end);
- } else {
- result.push({ ...range });
- }
- return result;
- }, []);
-
- return merged.reverse().map(({ start, end }) => ({
- replaceFromOffset: start,
- replaceToOffset: end,
- insertText: "",
- }));
-}
-
export function useAgentAddressLockPicker({
applyAutocompleteEdit,
audience,
@@ -177,13 +138,21 @@ export function useAgentAddressLockPicker({
],
);
- const removeAddressedAgent = React.useCallback(
+ const unpinAddressedAgent = React.useCallback(
(pubkey: string) => {
const normalized = normalizePubkey(pubkey);
if (!audienceScope || !normalized) return;
unpinnedAgentPubkeysRef.current.add(normalized);
const excludePubkey = audience.excludePubkey ?? audience.removePubkey;
excludePubkey(normalized);
+ },
+ [audience.excludePubkey, audience.removePubkey, audienceScope],
+ );
+ const removeAddressedAgent = React.useCallback(
+ (pubkey: string) => {
+ const normalized = normalizePubkey(pubkey);
+ if (!audienceScope || !normalized) return;
+ unpinAddressedAgent(normalized);
const displayName = lockedAgents.find(
(agent) => agent.pubkey === normalized,
)?.displayName;
@@ -206,43 +175,27 @@ export function useAgentAddressLockPicker({
},
[
applyAutocompleteEdit,
- audience.excludePubkey,
- audience.removePubkey,
audienceScope,
lockedAgents,
onImplicitPrefixRemoved,
richText.getPlainTextAndCursor,
- ],
- );
- const removeAddressedAgentMentions = React.useCallback(
- (pubkey: string) => {
- const normalized = normalizePubkey(pubkey);
- if (!audienceScope || !normalized) return;
- const { text } = richText.getPlainTextAndCursor();
- const matchingDisplayNames = mentions
- .getDraftMentionRefs(text)
- .filter((ref) => normalizePubkey(ref.pubkey) === normalized)
- .map((ref) => ref.displayName);
- for (const edit of buildMentionRemovalEdits(text, matchingDisplayNames)) {
- applyAutocompleteEdit(edit);
- }
- removeAddressedAgent(normalized);
- },
- [
- applyAutocompleteEdit,
- audienceScope,
- mentions.getDraftMentionRefs,
- removeAddressedAgent,
- richText.getPlainTextAndCursor,
+ unpinAddressedAgent,
],
);
const toggleAlwaysAddressAgent = React.useCallback(
- (suggestion: MentionSuggestion) => {
+ (
+ suggestion: MentionSuggestion,
+ options: { preserveMention?: boolean } = {},
+ ) => {
const pubkey = normalizePubkey(suggestion.pubkey ?? "");
if (!audienceScope || !pubkey || !suggestion.isAgent) return;
if (lockedAgentPubkeys.has(pubkey)) {
- removeAddressedAgentMentions(pubkey);
+ if (options.preserveMention) {
+ unpinAddressedAgent(pubkey);
+ } else {
+ removeAddressedAgent(pubkey);
+ }
setAnnouncement(
`Stopped automatically mentioning ${suggestion.displayName}`,
);
@@ -313,9 +266,10 @@ export function useAgentAddressLockPicker({
onAddressAgentMention,
onImplicitPrefixInserted,
onPulseAddressLock,
- removeAddressedAgentMentions,
+ removeAddressedAgent,
richText.getPlainTextAndCursor,
trackMentionAddressedAgent,
+ unpinAddressedAgent,
],
);
diff --git a/desktop/src/features/messages/ui/useThreadAgentAudience.ts b/desktop/src/features/messages/ui/useThreadAgentAudience.ts
new file mode 100644
index 00000000000..e10dad9cf06
--- /dev/null
+++ b/desktop/src/features/messages/ui/useThreadAgentAudience.ts
@@ -0,0 +1,36 @@
+import * as React from "react";
+
+import { useKeepMentionedAgentsPinned } from "@/features/messages/lib/autoPinMentionedAgentsPreference";
+import {
+ initializePersistentAgentAudience,
+ usePersistentAgentAudience,
+} from "@/features/messages/lib/persistentAgentAudience";
+
+export function useThreadAgentAudience({
+ isAgentPubkey,
+ rootTags,
+ scope,
+}: {
+ isAgentPubkey: (pubkey: string) => boolean;
+ rootTags: readonly string[][];
+ scope: string | null;
+}) {
+ const audience = usePersistentAgentAudience(scope);
+ const keepMentionedAgentsPinned = useKeepMentionedAgentsPinned();
+
+ const rootAgentPubkeys = React.useMemo(
+ () =>
+ rootTags.flatMap((tag) => {
+ const pubkey = tag[0] === "p" ? tag[1] : null;
+ return pubkey && isAgentPubkey(pubkey) ? [pubkey] : [];
+ }),
+ [isAgentPubkey, rootTags],
+ );
+
+ React.useEffect(() => {
+ if (!scope || !keepMentionedAgentsPinned) return;
+ initializePersistentAgentAudience(scope, rootAgentPubkeys);
+ }, [keepMentionedAgentsPinned, rootAgentPubkeys, scope]);
+
+ return { audience, keepMentionedAgentsPinned };
+}
diff --git a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx
index 50538868d05..b2fde755ff2 100644
--- a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx
+++ b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx
@@ -37,7 +37,7 @@ export function AgentsSettingsPanel() {
className="mt-0.5 text-sm text-muted-foreground/70"
data-settings-subcopy
>
- After you mention them once
+ Address selected agents in thread replies