mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(composer): polish automatic mentions (#6956)
**Category:** improvement **User Impact:** Automatic mentions are easier to turn off and now behave consistently across conversations, settings, drafts, and repeated agent mentions. **Problem:** People found the new automatic mention behavior hard to control: turning it off in Settings did not reliably affect the composer, removing a mention could require also disabling the feature, and root/thread composers could inherit or restore surprising state. Other reported rough edges included only one of several mentioned agents becoming automatic, synthetic mentions leaking into drafts, restored mentions corrupting adjacent text, controls remaining visible in archived channels, and unclear picker feedback. See the [original feedback thread](buzz://message?channel=e62570dd-33ad-42c5-b92b-75f2689f9694&id=c949ec399274fbb0d6633da3f95712e843a67dcada214f63d72f6975b406604b). **Solution:** Polish the existing feature around the problems people encountered, keeping automatic mentions controllable and scoped to the active conversation. | Reported issue | UX fix | | --- | --- | | Turning automatic mentions off in Settings did not reliably update the composer. | The global setting and composer control stay synchronized, and disabling the feature does not clear typed text. | | Removing an automatic mention could require both deleting the mention and turning off the feature. | Removing or unchecking an agent excludes that agent for the current conversation, while explicitly re-adding the agent can restore automatic mention behavior. | | Root and thread composers could share or restore surprising selections. | Each root or thread composer keeps its own automatic audience and restores it when the user returns. A request to enable automatic mentions only in agent threads was considered; this PR keeps them available at the channel root but prevents state from leaking between the two. | | Mentioning multiple agents could leave only one saved as automatic. | Multi-agent selections remain represented in the automatic audience and restored mention chips. | | Automatic mention prefixes could be saved as if the user typed them. | Synthetic prefixes stay out of persisted drafts while authored text is preserved. | | Restored mentions could lose their separator and corrupt continued typing. | Restored multi-word mentions retain their trailing space and place the caret after it. | | Archived channels showed automatic-mention state beside a disabled composer. | Disabled composers hide automatic-mention controls while preserving the draft and restoring state when re-enabled. | | Confirmation and picker behavior made the feature feel difficult to inspect or adjust. | Confirmations dismiss with removed agents, remain open while hovered, and expose the setting before it changes; pin icons, contrast, scope copy, animation, and keyboard toggling are also clarified. | | Agent suggestions and membership state could shift during directory refreshes. | Suggestions and membership labels stay stable during refreshes, while send-time authorization still revalidates access. | ## Changes <details> <summary>File changes</summary> **desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs** Adds coverage for the channel-roster eligibility rules used by agent mention autocomplete. **desktop/src/features/agents/lib/agentAutocompleteEligibility.ts** Aligns agent autocomplete eligibility with channel membership so available agents and their labels stay trustworthy. **desktop/src/features/channels/ui/MembersSidebar.tsx** Uses the shared member-pubkey logic when presenting and acting on channel members. **desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs** Covers preference changes that must remain stable while composer controls are toggled. **desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts** Keeps the automatic-mention preference as durable user intent rather than transient composer state. **desktop/src/features/messages/lib/mentionMemberPubkeys.ts** Centralizes which member identities count as mentionable in the current channel. **desktop/src/features/messages/lib/persistentAgentAudience.test.mjs** Expands lifecycle coverage for persistent agent audiences, explicit exclusions, and restored mentions. **desktop/src/features/messages/lib/persistentAgentAudience.ts** Models automatic, explicit, and excluded agent audiences separately so user choices survive updates without leaking across composers. **desktop/src/features/messages/lib/stripImplicitAgentMentions.test.mjs** Verifies implicit automatic mentions are removed without damaging surrounding separators or authored content. **desktop/src/features/messages/lib/stripImplicitAgentMentions.ts** Strips presentation-only automatic mentions before draft persistence while preserving whitespace and authored text. **desktop/src/features/messages/lib/useMentions.ts** Routes mention insertion and removal through the composer-local audience lifecycle. **desktop/src/features/messages/lib/useRichTextEditor.ts** Preserves mention-chip structure and caret placement when automatic mentions are restored. **desktop/src/features/messages/ui/ComposerAddressControls.test.mjs** Updates control-state expectations for disabled automatic mentions and restored pin affordances. **desktop/src/features/messages/ui/ComposerAddressControls.tsx** Makes automatic-mention state, disabled presentation, and pin controls visually explicit. **desktop/src/features/messages/ui/MentionAutocomplete.test.mjs** Adds coverage for roster labels, pin state, and picker behavior after mention selection. **desktop/src/features/messages/ui/MentionAutocomplete.tsx** Keeps the shortcut picker open for repeated selection and restores visible automatic-mention pin indicators. **desktop/src/features/messages/ui/MessageComposer.tsx** Scopes automatic mention state to each root or thread composer and coordinates restoration, draft persistence, and sending. **desktop/src/features/messages/ui/MessageComposerToolbar.tsx** Passes the effective automatic-mention state into the toolbar presentation. **desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs** Updates keyboard interaction coverage for toggling agents in place. **desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts** Restores automatic mention chips after lifecycle changes without moving or duplicating authored content. **desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs** Substantially expands coverage for toggles, exclusions, synchronization, and picker dismissal rules. **desktop/src/features/messages/ui/useAgentAddressLockPicker.ts** Keeps the picker usable across repeated choices and preserves explicit per-agent intent while settings change. **desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts** Makes the keyboard shortcut toggle the highlighted automatic audience choice without replacing unrelated selections. **desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts** Owns composer-local automatic-mention lifecycle behavior, including restoration, exclusions, deletion, and disabled-state handling. **desktop/src/features/messages/ui/useComposerMentionPicker.test.mjs** Adds focused picker lifecycle coverage for selection, hover, and dismissal behavior. **desktop/src/features/messages/ui/useComposerMentionPicker.ts** Prevents premature picker dismissal while the user is interacting with its controls. **desktop/src/features/messages/ui/useDraftPersistSnapshot.ts** Persists only user-authored draft content rather than implicit automatic mention decorations. **desktop/src/shared/lib/keyboard-shortcuts.ts** Updates the automatic-mention shortcut description to match its toggle behavior. **desktop/src/testing/e2eBridge.ts** Extends the desktop test bridge with the state needed to exercise roster and automatic-mention transitions. **desktop/tests/e2e/mentions.spec.ts** Covers roster-based labels, managed-agent invitation, revocation, and recovery behavior in the complete mention flow. **desktop/tests/e2e/persistent-agent-audience.spec.ts** Adds end-to-end coverage for root/thread isolation, preference synchronization, manual exclusions, draft hygiene, restored chips, separators, hover behavior, and disabled presentation. </details> ## Reproduction Steps 1. Open a channel with at least two available agents and enable automatic mentions from the composer mention control. 2. Select multiple agents, remove or uncheck one, and confirm subsequent composer updates keep that agent excluded while the others remain automatic. 3. Open a thread, choose a different automatic audience there, and switch between the thread and root composer; confirm each composer retains only its own choices. 4. Disable automatic mentions and confirm the draft text remains unchanged while automatic chips and controls show the disabled state; re-enable the setting and confirm eligible automatic chips return. 5. Delete an automatic mention chip, then explicitly add the agent again; confirm it immediately returns as an automatic mention without disturbing spaces or the caret, including for a multi-word name. 6. Reload with a saved draft and confirm implicit automatic mentions were not persisted as authored draft text. 7. Use the automatic-mention keyboard shortcut and picker repeatedly; confirm the picker remains open for additional choices and the highlighted agent toggles in place. ## Validation Validated at `34d208b47d64a9816f88e10a46bcfd479e917d75` after rebasing onto `origin/main` (`69096c9a8`): - Desktop unit tests: 5,731 passed, 0 failed. - Desktop TypeScript typecheck: passed. - Desktop E2E build: passed; emitted only existing chunk and dynamic-import warnings. - `pnpm check`: exited successfully; 4 warnings and 5 informational findings are in unrelated files introduced by current main. ## Screenshots/Demos The behavioral changes are covered by the focused desktop E2E scenarios above. Screenshots can be attached from the screenshot-producing automatic-mention E2E after the PR is created. --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz> Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
getAgentMentionAdmission,
|
||||
getMentionableAgentPubkeys,
|
||||
getSharedChannelIds,
|
||||
isAgentDirectoryReady,
|
||||
isAgentIdentityInAllowedList,
|
||||
isAgentMentionChannelType,
|
||||
relayAgentCanRespondInChannel,
|
||||
@@ -42,6 +43,15 @@ function makeAgent(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
test("isAgentDirectoryReady: requires successful cached directory evidence", () => {
|
||||
assert.equal(isAgentDirectoryReady({ data: [], error: null }), true);
|
||||
assert.equal(isAgentDirectoryReady({ data: undefined, error: null }), false);
|
||||
assert.equal(
|
||||
isAgentDirectoryReady({ data: [], error: new Error("offline") }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("getSharedChannelIds: includes only active joined channels", () => {
|
||||
assert.deepEqual(
|
||||
getSharedChannelIds([
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import type { Channel, RelayAgent } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export function isAgentDirectoryReady({
|
||||
data,
|
||||
error,
|
||||
}: {
|
||||
data: unknown;
|
||||
error: unknown;
|
||||
}) {
|
||||
// A successful cached directory remains suitable for autocomplete during a
|
||||
// refetch. Sending still re-fetches and fails closed at its authorization
|
||||
// boundary, so suggestions are hints rather than permission to send.
|
||||
return data !== undefined && error === null;
|
||||
}
|
||||
|
||||
export function getSharedChannelIds(channels: readonly Channel[] | undefined) {
|
||||
return new Set(
|
||||
(channels ?? [])
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
coalesceAgentAutocompleteCandidates,
|
||||
getMentionableAgentPubkeys,
|
||||
getSharedChannelIds,
|
||||
isAgentDirectoryReady,
|
||||
isAgentIdentityInAllowedList,
|
||||
} from "@/features/agents/lib/agentAutocompleteEligibility";
|
||||
import { isOtherSetupAgent } from "@/features/agents/lib/otherSetupAgent";
|
||||
@@ -184,12 +185,8 @@ export function MembersSidebar({
|
||||
relayAgentsQuery,
|
||||
} = useClassifiedMembers(rawMembers, currentPubkey);
|
||||
const agentDirectoriesReady =
|
||||
managedAgentsQuery.data !== undefined &&
|
||||
managedAgentsQuery.error === null &&
|
||||
!managedAgentsQuery.isFetching &&
|
||||
relayAgentsQuery.data !== undefined &&
|
||||
relayAgentsQuery.error === null &&
|
||||
!relayAgentsQuery.isFetching;
|
||||
isAgentDirectoryReady(managedAgentsQuery) &&
|
||||
isAgentDirectoryReady(relayAgentsQuery);
|
||||
const activeMembers = React.useMemo(
|
||||
() =>
|
||||
[...people, ...bots].sort((left, right) =>
|
||||
|
||||
@@ -8,6 +8,7 @@ globalThis.localStorage = {
|
||||
};
|
||||
|
||||
const preference = await import("./autoPinMentionedAgentsPreference.ts");
|
||||
const persistentAudience = await import("./persistentAgentAudience.ts");
|
||||
|
||||
test("defaults missing and invalid values to one-time agent mentions", () => {
|
||||
assert.equal(preference.parseKeepMentionedAgentsPinned(null), false);
|
||||
@@ -31,3 +32,16 @@ test("persists changes to the post-mention pinning preference", () => {
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
test("turning off automatic mentions clears active conversation audiences", () => {
|
||||
const scope = `${"1".repeat(64)}:channel-a:channel`;
|
||||
persistentAudience.setPersistentAgentAudience(scope, ["a".repeat(64)]);
|
||||
preference.setKeepMentionedAgentsPinned(true);
|
||||
|
||||
preference.setKeepMentionedAgentsPinned(false);
|
||||
|
||||
assert.deepEqual(
|
||||
persistentAudience.getPersistentAgentAudienceSnapshot().audiences,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { resetPersistentAgentAudienceStore } from "./persistentAgentAudience";
|
||||
|
||||
export const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY =
|
||||
"buzz.messages.keepMentionedAgentsPinned";
|
||||
export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = false;
|
||||
@@ -37,6 +39,7 @@ export function getKeepMentionedAgentsPinned(): boolean {
|
||||
}
|
||||
|
||||
export function setKeepMentionedAgentsPinned(value: boolean): void {
|
||||
if (!value) resetPersistentAgentAudienceStore();
|
||||
if (value === keepMentionedAgentsPinned) return;
|
||||
keepMentionedAgentsPinned = value;
|
||||
try {
|
||||
|
||||
@@ -23,6 +23,7 @@ function input(overrides = {}) {
|
||||
managedAgents: [],
|
||||
memberPubkeys: new Set(),
|
||||
members: [],
|
||||
mentionChannelId: null,
|
||||
mentionableAgentPubkeys: new Set(),
|
||||
personaNameByPubkey: new Map(),
|
||||
profiles: undefined,
|
||||
|
||||
@@ -36,6 +36,7 @@ export type BuildMentionCandidatesInput = {
|
||||
managedAgents: readonly ManagedAgent[] | undefined;
|
||||
memberPubkeys: ReadonlySet<string>;
|
||||
members: readonly ChannelMember[] | undefined;
|
||||
mentionChannelId: string | null;
|
||||
mentionableAgentPubkeys: ReadonlySet<string>;
|
||||
personaNameByPubkey: ReadonlyMap<string, string>;
|
||||
profiles: UserProfileLookup | undefined;
|
||||
@@ -66,6 +67,7 @@ export function buildMentionCandidates({
|
||||
managedAgents,
|
||||
memberPubkeys,
|
||||
members,
|
||||
mentionChannelId,
|
||||
mentionableAgentPubkeys,
|
||||
personaNameByPubkey,
|
||||
profiles,
|
||||
@@ -167,7 +169,13 @@ export function buildMentionCandidates({
|
||||
kind: "identity",
|
||||
pubkey,
|
||||
displayName: agent.name,
|
||||
isMember: false,
|
||||
// Prefer the active channel's signed roster. The relay-agent directory
|
||||
// is filtered by access policy, so its channel ids can legitimately omit
|
||||
// a room where this identity is already a member.
|
||||
isMember:
|
||||
memberPubkeys.has(pubkey) ||
|
||||
(mentionChannelId !== null &&
|
||||
agent.channelIds.includes(mentionChannelId)),
|
||||
personaId:
|
||||
managedAgentPersonaIdsByPubkey.get(pubkey) ??
|
||||
(activePersonaById.has(pubkey) ? pubkey : undefined),
|
||||
@@ -177,11 +185,12 @@ export function buildMentionCandidates({
|
||||
});
|
||||
}
|
||||
for (const agent of managedAgents ?? []) {
|
||||
const pubkey = normalizePubkey(agent.pubkey);
|
||||
addCandidate({
|
||||
kind: "identity",
|
||||
pubkey: agent.pubkey,
|
||||
pubkey,
|
||||
displayName: agent.name,
|
||||
isMember: false,
|
||||
isMember: memberPubkeys.has(pubkey),
|
||||
isAgent: true,
|
||||
isActiveAgent: agent.status === "running" || agent.status === "deployed",
|
||||
isManagedAgent: true,
|
||||
|
||||
@@ -431,7 +431,12 @@ export const MentionHighlightExtension = Extension.create({
|
||||
applying = false;
|
||||
}
|
||||
}
|
||||
setDomCaretAtPos(view, view.state.selection.from);
|
||||
// Highlight refreshes can land after the user has moved focus to
|
||||
// a popover. Keep settlement armed for the next keystroke, but
|
||||
// never drag DOM selection back into an unfocused composer.
|
||||
if (view.hasFocus()) {
|
||||
setDomCaretAtPos(view, view.state.selection.from);
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
settlement.cancel();
|
||||
|
||||
19
desktop/src/features/messages/lib/mentionMemberPubkeys.ts
Normal file
19
desktop/src/features/messages/lib/mentionMemberPubkeys.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Channel, ChannelMember } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { channelMemberPubkeySet } from "@/shared/lib/rosterDerivations";
|
||||
|
||||
/** Merge the dedicated roster with the active channel's signed projection. */
|
||||
export function getMentionMemberPubkeys(
|
||||
channelId: string | null,
|
||||
channels: readonly Channel[] | undefined,
|
||||
members: ChannelMember[] | undefined,
|
||||
): Set<string> {
|
||||
const pubkeys = new Set(
|
||||
members ? channelMemberPubkeySet(members) : undefined,
|
||||
);
|
||||
const activeChannel = channels?.find((channel) => channel.id === channelId);
|
||||
for (const pubkey of activeChannel?.memberPubkeys ?? []) {
|
||||
pubkeys.add(normalizePubkey(pubkey));
|
||||
}
|
||||
return pubkeys;
|
||||
}
|
||||
@@ -134,20 +134,29 @@ test("reset clears every audience for refresh and community boundaries", async (
|
||||
assert.deepEqual(currentAudiences(store), {});
|
||||
});
|
||||
|
||||
test("channel and thread composers share the channel audience scope", async () => {
|
||||
test("channel and thread composers have independent audience scopes", async () => {
|
||||
const store = await loadStore(8);
|
||||
const channelScope = store.getPersistentAgentAudienceScope({
|
||||
ownerPubkey: ownerA,
|
||||
channelId: "channel-a",
|
||||
composerKey: "channel-a",
|
||||
});
|
||||
const threadScope = store.getPersistentAgentAudienceScope({
|
||||
ownerPubkey: ownerA,
|
||||
channelId: "channel-a",
|
||||
threadRootId: "root",
|
||||
composerKey: "thread:root",
|
||||
});
|
||||
const otherThreadScope = store.getPersistentAgentAudienceScope({
|
||||
ownerPubkey: ownerA,
|
||||
channelId: "channel-a",
|
||||
composerKey: "thread:other-root",
|
||||
});
|
||||
|
||||
assert.equal(channelScope, `${ownerA}:channel-a:channel`);
|
||||
assert.equal(threadScope, channelScope);
|
||||
assert.equal(threadScope, `${ownerA}:channel-a:thread:root`);
|
||||
assert.equal(otherThreadScope, `${ownerA}:channel-a:thread:other-root`);
|
||||
assert.notEqual(threadScope, channelScope);
|
||||
assert.notEqual(otherThreadScope, threadScope);
|
||||
});
|
||||
|
||||
test("delayed promotion cannot overwrite a newer audience choice", async () => {
|
||||
@@ -191,6 +200,42 @@ test("stale auto-pin Undo cannot remove a newer explicit choice", async () => {
|
||||
assert.deepEqual(currentAudiences(store), { [scope]: [agentA] });
|
||||
});
|
||||
|
||||
test("explicitly excluded agents are not auto-promoted again", async () => {
|
||||
const store = await loadStore(12);
|
||||
const scope = `${ownerA}:channel-a:channel`;
|
||||
store.setPersistentAgentAudience(scope, [agentA]);
|
||||
store.excludePersistentAgentAudienceMember(scope, agentA);
|
||||
|
||||
const promotion = store.promotePersistentAgentAudienceIfUnchanged({
|
||||
expectedRevision: store.getPersistentAgentAudienceRevision(scope),
|
||||
pubkeys: [agentA],
|
||||
scope,
|
||||
});
|
||||
|
||||
assert.equal(promotion, null);
|
||||
assert.deepEqual(currentAudiences(store), { [scope]: [] });
|
||||
|
||||
store.addPersistentAgentAudienceMember(scope, agentA);
|
||||
assert.deepEqual(currentAudiences(store), { [scope]: [agentA] });
|
||||
});
|
||||
|
||||
test("explicit re-selection reinstates an excluded agent", async () => {
|
||||
const store = await loadStore(13);
|
||||
const scope = `${ownerA}:channel-a:channel`;
|
||||
store.setPersistentAgentAudience(scope, [agentA]);
|
||||
store.excludePersistentAgentAudienceMember(scope, agentA);
|
||||
|
||||
const promotion = store.promotePersistentAgentAudienceIfUnchanged({
|
||||
expectedRevision: store.getPersistentAgentAudienceRevision(scope),
|
||||
reinstateExcluded: true,
|
||||
pubkeys: [agentA],
|
||||
scope,
|
||||
});
|
||||
|
||||
assert.deepEqual(promotion?.promotedPubkeys, [agentA]);
|
||||
assert.deepEqual(currentAudiences(store), { [scope]: [agentA] });
|
||||
});
|
||||
|
||||
test("promotion reports only newly added agents for transactional Undo", async () => {
|
||||
const store = await loadStore(11);
|
||||
const scope = `${ownerA}:channel-a:channel`;
|
||||
|
||||
@@ -4,6 +4,7 @@ export const MAX_IN_MEMORY_AGENT_AUDIENCES = 200;
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
const revisions = new Map<string, number>();
|
||||
const excludedPubkeysByScope = new Map<string, Set<string>>();
|
||||
let revisionClock = 0;
|
||||
let defaultRevision = 0;
|
||||
let audiences: Record<string, string[]> = {};
|
||||
@@ -16,7 +17,7 @@ export type PersistentAgentAudienceSnapshot = Readonly<{
|
||||
type PersistentAgentAudienceScopeInput = {
|
||||
ownerPubkey: string;
|
||||
channelId: string;
|
||||
threadRootId?: string | null;
|
||||
composerKey?: string | null;
|
||||
};
|
||||
|
||||
function normalizePubkeys(pubkeys: Iterable<string>): string[] {
|
||||
@@ -46,17 +47,22 @@ function emit(): void {
|
||||
export function getPersistentAgentAudienceScope({
|
||||
ownerPubkey,
|
||||
channelId,
|
||||
composerKey,
|
||||
}: PersistentAgentAudienceScopeInput): string | null {
|
||||
const owner = ownerPubkey.trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(owner) || !channelId) return null;
|
||||
// Thread composers intentionally share their parent channel's audience.
|
||||
return `${owner}:${channelId}:channel`;
|
||||
const composer =
|
||||
composerKey?.trim() && composerKey.trim() !== channelId
|
||||
? composerKey.trim()
|
||||
: "channel";
|
||||
return `${owner}:${channelId}:${composer}`;
|
||||
}
|
||||
|
||||
export function resetPersistentAgentAudienceStore(): void {
|
||||
revisionClock += 1;
|
||||
defaultRevision = revisionClock;
|
||||
revisions.clear();
|
||||
excludedPubkeysByScope.clear();
|
||||
audiences = {};
|
||||
emit();
|
||||
}
|
||||
@@ -83,6 +89,11 @@ export function setPersistentAgentAudience(
|
||||
const nextAudiences = { ...audiences };
|
||||
delete nextAudiences[scope];
|
||||
audiences = boundAudiences({ ...nextAudiences, [scope]: normalized });
|
||||
for (const excludedScope of excludedPubkeysByScope.keys()) {
|
||||
if (!Object.hasOwn(audiences, excludedScope)) {
|
||||
excludedPubkeysByScope.delete(excludedScope);
|
||||
}
|
||||
}
|
||||
for (const revisedScope of revisions.keys()) {
|
||||
if (!Object.hasOwn(audiences, revisedScope)) revisions.delete(revisedScope);
|
||||
}
|
||||
@@ -97,19 +108,29 @@ export function getPersistentAgentAudienceRevision(scope: string): number {
|
||||
|
||||
export function promotePersistentAgentAudienceIfUnchanged({
|
||||
expectedRevision,
|
||||
reinstateExcluded = false,
|
||||
pubkeys,
|
||||
scope,
|
||||
}: {
|
||||
expectedRevision: number;
|
||||
reinstateExcluded?: boolean;
|
||||
pubkeys: Iterable<string>;
|
||||
scope: string;
|
||||
}): { promotedPubkeys: string[]; revision: number } | null {
|
||||
if (getPersistentAgentAudienceRevision(scope) !== expectedRevision)
|
||||
return null;
|
||||
const promotedPubkeys = normalizePubkeys(pubkeys).filter(
|
||||
(pubkey) => !(audiences[scope] ?? []).includes(pubkey),
|
||||
const normalizedPubkeys = normalizePubkeys(pubkeys);
|
||||
const promotedPubkeys = normalizedPubkeys.filter(
|
||||
(pubkey) =>
|
||||
!(audiences[scope] ?? []).includes(pubkey) &&
|
||||
(reinstateExcluded || !excludedPubkeysByScope.get(scope)?.has(pubkey)),
|
||||
);
|
||||
if (promotedPubkeys.length === 0) return null;
|
||||
if (reinstateExcluded) {
|
||||
const excluded = excludedPubkeysByScope.get(scope);
|
||||
for (const pubkey of promotedPubkeys) excluded?.delete(pubkey);
|
||||
if (excluded?.size === 0) excludedPubkeysByScope.delete(scope);
|
||||
}
|
||||
setPersistentAgentAudience(scope, [
|
||||
...(audiences[scope] ?? []),
|
||||
...promotedPubkeys,
|
||||
@@ -143,7 +164,22 @@ export function addPersistentAgentAudienceMember(
|
||||
scope: string,
|
||||
pubkey: string,
|
||||
): void {
|
||||
setPersistentAgentAudience(scope, [...(audiences[scope] ?? []), pubkey]);
|
||||
const normalized = normalizePubkeys([pubkey])[0];
|
||||
if (!normalized) return;
|
||||
excludedPubkeysByScope.get(scope)?.delete(normalized);
|
||||
setPersistentAgentAudience(scope, [...(audiences[scope] ?? []), normalized]);
|
||||
}
|
||||
|
||||
export function excludePersistentAgentAudienceMember(
|
||||
scope: string,
|
||||
pubkey: string,
|
||||
): void {
|
||||
const normalized = normalizePubkeys([pubkey])[0];
|
||||
if (!scope || !normalized) return;
|
||||
const excluded = excludedPubkeysByScope.get(scope) ?? new Set<string>();
|
||||
excluded.add(normalized);
|
||||
excludedPubkeysByScope.set(scope, excluded);
|
||||
removePersistentAgentAudienceMember(scope, normalized);
|
||||
}
|
||||
|
||||
export function removePersistentAgentAudienceMember(
|
||||
@@ -179,6 +215,7 @@ export function usePersistentAgentAudience(scope: string | null): {
|
||||
pubkeys: readonly string[];
|
||||
addPubkey: (pubkey: string) => void;
|
||||
removePubkey: (pubkey: string) => void;
|
||||
excludePubkey: (pubkey: string) => void;
|
||||
clear: () => void;
|
||||
} {
|
||||
const state = React.useSyncExternalStore(
|
||||
@@ -197,6 +234,10 @@ export function usePersistentAgentAudience(scope: string | null): {
|
||||
(pubkey) => removePersistentAgentAudienceMember(resolvedScope, pubkey),
|
||||
[resolvedScope],
|
||||
),
|
||||
excludePubkey: React.useCallback(
|
||||
(pubkey) => excludePersistentAgentAudienceMember(resolvedScope, pubkey),
|
||||
[resolvedScope],
|
||||
),
|
||||
clear: React.useCallback(
|
||||
() => setPersistentAgentAudience(resolvedScope, []),
|
||||
[resolvedScope],
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { stripImplicitAgentMentionPrefix } from "./stripImplicitAgentMentions.ts";
|
||||
|
||||
test("removes the exact synthesized leading prefix", () => {
|
||||
assert.equal(
|
||||
stripImplicitAgentMentionPrefix("@Morgarita draft text", "@Morgarita "),
|
||||
"draft text",
|
||||
);
|
||||
});
|
||||
|
||||
test("removes the complete captured prefix for multiple agents", () => {
|
||||
assert.equal(
|
||||
stripImplicitAgentMentionPrefix(
|
||||
"@Morgarita @Vogue draft text",
|
||||
"@Morgarita @Vogue ",
|
||||
),
|
||||
"draft text",
|
||||
);
|
||||
});
|
||||
|
||||
test("removes an implicit-only mention when markdown drops its separator", () => {
|
||||
assert.equal(
|
||||
stripImplicitAgentMentionPrefix("@Morgarita", "@Morgarita "),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves an identical authored mention after the synthesized prefix", () => {
|
||||
assert.equal(
|
||||
stripImplicitAgentMentionPrefix(
|
||||
"@Morgarita @Morgarita authored duplicate",
|
||||
"@Morgarita ",
|
||||
),
|
||||
"@Morgarita authored duplicate",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves content when the captured prefix does not match exactly", () => {
|
||||
assert.equal(
|
||||
stripImplicitAgentMentionPrefix("@Alice ask @Morgarita", "@Morgarita "),
|
||||
"@Alice ask @Morgarita",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Removes the exact leading prefix synthesized by automatic agent addressing.
|
||||
* The captured prefix is provenance: an identical authored mention immediately
|
||||
* after it must remain draft content.
|
||||
*/
|
||||
export function stripImplicitAgentMentionPrefix(
|
||||
content: string,
|
||||
implicitPrefix: string,
|
||||
): string {
|
||||
if (!implicitPrefix) return content;
|
||||
if (content.startsWith(implicitPrefix)) {
|
||||
return content.slice(implicitPrefix.length);
|
||||
}
|
||||
return content === implicitPrefix.trimEnd() ? "" : content;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getAgentIdentityPubkeys,
|
||||
getMentionableAgentPubkeys,
|
||||
getSharedChannelIds,
|
||||
isAgentDirectoryReady,
|
||||
isAgentMentionChannelType,
|
||||
rememberSelectedAgentPubkeys,
|
||||
uniqueAutocompleteLabels,
|
||||
@@ -32,7 +33,6 @@ import type { ChannelMember, ChannelType } from "@/shared/api/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { channelMemberPubkeySet } from "@/shared/lib/rosterDerivations";
|
||||
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
|
||||
import { useActiveAgentPubkeys } from "./useActiveAgentPubkeys";
|
||||
import { useDefaultAgentSuggestion } from "./useDefaultAgentSuggestion";
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from "./useMentionSelection";
|
||||
import { rankMentionCandidates } from "./mentionRanking";
|
||||
import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping";
|
||||
import { getMentionMemberPubkeys } from "./mentionMemberPubkeys";
|
||||
import {
|
||||
appendUniqueName,
|
||||
buildTeamMentionCandidates,
|
||||
@@ -99,14 +100,8 @@ export function useMentions(
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const personasQuery = usePersonasQuery();
|
||||
const teamsQuery = useTeamsQuery();
|
||||
const managedAgentDirectoryReady =
|
||||
managedAgentsQuery.data !== undefined &&
|
||||
managedAgentsQuery.error === null &&
|
||||
!managedAgentsQuery.isFetching;
|
||||
const relayAgentDirectoryReady =
|
||||
relayAgentsQuery.data !== undefined &&
|
||||
relayAgentsQuery.error === null &&
|
||||
!relayAgentsQuery.isFetching;
|
||||
const managedAgentDirectoryReady = isAgentDirectoryReady(managedAgentsQuery);
|
||||
const relayAgentDirectoryReady = isAgentDirectoryReady(relayAgentsQuery);
|
||||
const agentDirectoriesReady =
|
||||
managedAgentDirectoryReady && relayAgentDirectoryReady;
|
||||
const canSearchGlobalUsers = canSearchGlobalPeople && agentDirectoriesReady;
|
||||
@@ -228,11 +223,9 @@ export function useMentions(
|
||||
() => new Set(activePersonas.map((persona) => persona.id)),
|
||||
[activePersonas],
|
||||
);
|
||||
// Identity-cached (shared with the timeline's roster derivations) — the
|
||||
// Set is built once per distinct roster instead of per consumer.
|
||||
const memberPubkeys = React.useMemo(
|
||||
() => (members ? channelMemberPubkeySet(members) : new Set<string>()),
|
||||
[members],
|
||||
() => getMentionMemberPubkeys(channelId, channelsQuery.data, members),
|
||||
[channelId, channelsQuery.data, members],
|
||||
);
|
||||
const agentIdentityPubkeys = React.useMemo(
|
||||
() =>
|
||||
@@ -260,6 +253,7 @@ export function useMentions(
|
||||
managedAgents: managedAgentsQuery.data,
|
||||
memberPubkeys,
|
||||
members,
|
||||
mentionChannelId,
|
||||
mentionableAgentPubkeys,
|
||||
personaNameByPubkey,
|
||||
profiles,
|
||||
@@ -283,6 +277,7 @@ export function useMentions(
|
||||
managedAgentsQuery.data,
|
||||
memberPubkeys,
|
||||
members,
|
||||
mentionChannelId,
|
||||
mentionableAgentPubkeys,
|
||||
personaNameByPubkey,
|
||||
profiles,
|
||||
@@ -535,11 +530,11 @@ export function useMentions(
|
||||
appendUniqueName(current, trimmedName),
|
||||
);
|
||||
if (options?.isAgent) {
|
||||
setSelectedAgentMentionNames((current) => {
|
||||
const next = appendUniqueName(current, trimmedName);
|
||||
selectedAgentMentionNamesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
selectedAgentMentionNamesRef.current = appendUniqueName(
|
||||
selectedAgentMentionNamesRef.current,
|
||||
trimmedName,
|
||||
);
|
||||
setSelectedAgentMentionNames(selectedAgentMentionNamesRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -74,6 +74,8 @@ export type AutocompleteEdit = {
|
||||
insertText: string;
|
||||
/** Keep the current selection mapped through this edit instead of moving it to the insertion. */
|
||||
preserveSelection?: boolean;
|
||||
/** Skip asynchronous DOM caret reassertion when focus may move elsewhere. */
|
||||
reassertMentionCaret?: boolean;
|
||||
/**
|
||||
* When set, the replaced range becomes a CustomEmojiNode for this
|
||||
* shortcode (followed by `insertText`, which carries the trailing space)
|
||||
@@ -162,6 +164,7 @@ export function useRichTextEditor({
|
||||
onLinkSelectionChange,
|
||||
onLinkShortcut,
|
||||
}: RichTextEditorOptions) {
|
||||
const addressedAgentMentionNamesRef = React.useRef<readonly string[]>([]);
|
||||
const onUpdateRef = React.useRef(onUpdate);
|
||||
onUpdateRef.current = onUpdate;
|
||||
|
||||
@@ -648,11 +651,30 @@ export function useRichTextEditor({
|
||||
syncMentionHighlightFromProps(
|
||||
editor,
|
||||
mentionNames,
|
||||
agentMentionNames,
|
||||
[
|
||||
...new Set([
|
||||
...(agentMentionNames ?? []),
|
||||
...addressedAgentMentionNamesRef.current,
|
||||
]),
|
||||
],
|
||||
channelNames,
|
||||
);
|
||||
}, [editor, mentionNames, agentMentionNames, channelNames]);
|
||||
|
||||
const syncAddressedAgentMentionNames = React.useCallback(
|
||||
(names: readonly string[]) => {
|
||||
addressedAgentMentionNamesRef.current = names;
|
||||
if (!editor) return;
|
||||
syncMentionHighlightFromProps(
|
||||
editor,
|
||||
mentionNames,
|
||||
[...new Set([...(agentMentionNames ?? []), ...names])],
|
||||
channelNames,
|
||||
);
|
||||
},
|
||||
[agentMentionNames, channelNames, editor, mentionNames],
|
||||
);
|
||||
|
||||
// Custom-emoji set changes: re-resolve the `src` attr on any existing
|
||||
// node in the doc (e.g. an emoji's image was just published).
|
||||
React.useEffect(() => {
|
||||
@@ -777,6 +799,7 @@ export function useRichTextEditor({
|
||||
text: string,
|
||||
customEmojiShortcode?: string,
|
||||
preserveSelection = false,
|
||||
reassertMentionCaret = !preserveSelection,
|
||||
) => {
|
||||
if (!editor) return;
|
||||
const projection = buildPlainTextProjection(editor.state.doc);
|
||||
@@ -825,7 +848,7 @@ export function useRichTextEditor({
|
||||
settleAutocompleteMentionInsert(editor, tr, text, !preserveSelection);
|
||||
editor.view.dispatch(tr);
|
||||
editor.view.focus();
|
||||
if (!preserveSelection) reassertMentionCaretAfterFocus(editor.view);
|
||||
if (reassertMentionCaret) reassertMentionCaretAfterFocus(editor.view);
|
||||
},
|
||||
[editor, customEmojiWiring.resolveUrl],
|
||||
);
|
||||
@@ -917,6 +940,7 @@ export function useRichTextEditor({
|
||||
focusPreserve,
|
||||
getPlainTextAndCursor,
|
||||
replacePlainTextRange,
|
||||
syncAddressedAgentMentionNames,
|
||||
getLinkSelectionInfo,
|
||||
applyLink,
|
||||
removeLink,
|
||||
|
||||
@@ -111,10 +111,16 @@ test("mention control expands with automatically mentioned agents", async () =>
|
||||
);
|
||||
}
|
||||
const remove = view.getByTestId("composer-address-lock-remove-agent-pubkey");
|
||||
const removeChrome = remove.querySelector("span.absolute");
|
||||
assert.match(
|
||||
remove.querySelector("span.absolute")?.className ?? "",
|
||||
removeChrome?.className ?? "",
|
||||
/group-hover\/address:opacity-100/,
|
||||
);
|
||||
assert.match(removeChrome?.className ?? "", /(?:^|\s)bg-foreground(?:\s|$)/);
|
||||
assert.doesNotMatch(
|
||||
removeChrome?.className ?? "",
|
||||
/(?:^|\s)bg-foreground\/80(?:\s|$)/,
|
||||
);
|
||||
fireEvent.click(remove);
|
||||
assert.deepEqual(removed, ["agent-pubkey"]);
|
||||
view.rerender(renderButton([]));
|
||||
|
||||
@@ -129,6 +129,7 @@ export function ComposerMentionButton({
|
||||
confirmationTitle,
|
||||
disabled,
|
||||
onConfirmationDismiss,
|
||||
onConfirmationHoverChange,
|
||||
onConfirmationTurnOff,
|
||||
onCaptureSelection,
|
||||
onOpen,
|
||||
@@ -140,6 +141,7 @@ export function ComposerMentionButton({
|
||||
confirmationTitle?: string | null;
|
||||
disabled: boolean;
|
||||
onConfirmationDismiss?: () => void;
|
||||
onConfirmationHoverChange?: (hovered: boolean) => void;
|
||||
onConfirmationTurnOff?: () => void;
|
||||
onCaptureSelection: () => void;
|
||||
onOpen: () => void;
|
||||
@@ -216,11 +218,11 @@ export function ComposerMentionButton({
|
||||
className="flex items-center gap-1 overflow-hidden"
|
||||
data-testid="composer-address-locks"
|
||||
exit={{ opacity: 0, width: 0 }}
|
||||
initial={false}
|
||||
initial={shouldReduceMotion ? false : { opacity: 0, width: 0 }}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.18, ease: "easeOut" }
|
||||
: { duration: 0.12, ease: "easeOut" }
|
||||
}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
@@ -228,7 +230,7 @@ export function ComposerMentionButton({
|
||||
<Tooltip disableHoverableContent key={agent.pubkey}>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.button
|
||||
aria-label={`Stop automatically mentioning ${agent.displayName}`}
|
||||
aria-label={`Don't automatically mention ${agent.displayName} in this conversation`}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="group/address relative rounded-full focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
|
||||
data-testid={`composer-address-lock-remove-${agent.pubkey}`}
|
||||
@@ -263,13 +265,14 @@ export function ComposerMentionButton({
|
||||
shakeVersionByPubkey[agent.pubkey] ?? 0
|
||||
}
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full bg-foreground/80 text-background opacity-0 transition-opacity group-hover/address:opacity-100 group-focus-visible/address:opacity-100">
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full bg-foreground text-background opacity-0 transition-opacity group-hover/address:opacity-100 group-focus-visible/address:opacity-100">
|
||||
<X aria-hidden="true" className="h-3 w-3" />
|
||||
</span>
|
||||
</motion.button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Stop automatically mentioning {agent.displayName}
|
||||
Don't automatically mention {agent.displayName} in this
|
||||
conversation
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
@@ -289,6 +292,8 @@ export function ComposerMentionButton({
|
||||
data-testid="composer-auto-pin-confirmation"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onPointerEnter={() => onConfirmationHoverChange?.(true)}
|
||||
onPointerLeave={() => onConfirmationHoverChange?.(false)}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
style={{ width: "max-content" }}
|
||||
|
||||
@@ -77,6 +77,11 @@ test("agent rows offer automatic mention controls", async () => {
|
||||
});
|
||||
assert.equal(action.getAttribute("aria-pressed"), "false");
|
||||
assert.equal(action.getAttribute("data-state"), "off");
|
||||
const inactivePin = action.querySelector(
|
||||
'[data-testid="mention-auto-pin-icon"]',
|
||||
);
|
||||
assert.match(inactivePin?.getAttribute("class") ?? "", /\blucide-pin\b/);
|
||||
assert.equal(inactivePin?.getAttribute("fill"), "none");
|
||||
fireEvent.click(action);
|
||||
assert.deepEqual(toggled, [suggestion]);
|
||||
assert.deepEqual(selected, [suggestion]);
|
||||
@@ -88,10 +93,14 @@ test("agent rows offer automatic mention controls", async () => {
|
||||
}),
|
||||
);
|
||||
const selectedAction = view.getByRole("button", {
|
||||
name: "Stop automatically mentioning Agent Ada",
|
||||
name: "Don't automatically mention Agent Ada in this conversation",
|
||||
});
|
||||
assert.equal(selectedAction.getAttribute("aria-pressed"), "true");
|
||||
assert.equal(selectedAction.getAttribute("data-state"), "on");
|
||||
const activePin = selectedAction.querySelector(
|
||||
'[data-testid="mention-auto-pin-icon"]',
|
||||
);
|
||||
assert.equal(activePin?.getAttribute("fill"), "currentColor");
|
||||
fireEvent.click(selectedAction);
|
||||
assert.deepEqual(toggled, [suggestion, suggestion]);
|
||||
});
|
||||
@@ -168,6 +177,56 @@ test("options expand in place without replacing the people list", async () => {
|
||||
assert.ok(view.getByRole("button", { name: "Mention Agent Ada" }));
|
||||
});
|
||||
|
||||
test("automatic selection loads the setting once, then updates it in place", async () => {
|
||||
const React = await import("react");
|
||||
const { render } = await import("@testing-library/react");
|
||||
const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx");
|
||||
const suggestion = {
|
||||
pubkey: "agent-pubkey",
|
||||
displayName: "Agent Ada",
|
||||
isAgent: true,
|
||||
};
|
||||
const props = {
|
||||
suggestions: [suggestion],
|
||||
selectedIndex: 0,
|
||||
onSelect: () => {},
|
||||
keepMentionedAgentsPinned: false,
|
||||
onKeepMentionedAgentsPinnedChange: () => {},
|
||||
};
|
||||
const view = render(
|
||||
React.createElement(MentionAutocomplete, {
|
||||
...props,
|
||||
openOptionsRequest: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
view.rerender(
|
||||
React.createElement(MentionAutocomplete, {
|
||||
...props,
|
||||
openOptionsRequest: 1,
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
view.getByRole("button", { name: "Options" }).getAttribute("aria-expanded"),
|
||||
"true",
|
||||
);
|
||||
const toggle = view.getByRole("switch", {
|
||||
name: "Automatically mention agents",
|
||||
});
|
||||
const settings = view.getByTestId("mention-options-settings");
|
||||
assert.equal(toggle.getAttribute("data-state"), "unchecked");
|
||||
|
||||
view.rerender(
|
||||
React.createElement(MentionAutocomplete, {
|
||||
...props,
|
||||
keepMentionedAgentsPinned: true,
|
||||
openOptionsRequest: 2,
|
||||
}),
|
||||
);
|
||||
assert.equal(view.getByTestId("mention-options-settings"), settings);
|
||||
assert.equal(toggle.getAttribute("data-state"), "checked");
|
||||
});
|
||||
|
||||
test("clicking outside dismisses the tray without intercepting its trigger", async () => {
|
||||
const React = await import("react");
|
||||
const { fireEvent, render } = await import("@testing-library/react");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { AtSign, Bot, ChevronRight, Users } from "lucide-react";
|
||||
import { Bot, ChevronRight, Pin, Users } from "lucide-react";
|
||||
import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
import { motion } from "motion/react";
|
||||
import type { TeamMentionMember } from "@/features/messages/lib/mentionCandidates";
|
||||
@@ -44,6 +44,7 @@ type MentionAutocompleteProps = {
|
||||
keepMentionedAgentsPinned?: boolean;
|
||||
onKeepMentionedAgentsPinnedChange?: (value: boolean) => void;
|
||||
openOptionsRequest?: number;
|
||||
onOptionsRevealComplete?: (request: number) => void;
|
||||
onDismiss?: () => void;
|
||||
position?: "above" | "below";
|
||||
};
|
||||
@@ -65,6 +66,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
keepMentionedAgentsPinned = true,
|
||||
onKeepMentionedAgentsPinnedChange,
|
||||
openOptionsRequest = 0,
|
||||
onOptionsRevealComplete,
|
||||
onDismiss,
|
||||
position = "above",
|
||||
}: MentionAutocompleteProps) {
|
||||
@@ -74,6 +76,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
const optionsId = React.useId();
|
||||
const keepPinnedSwitchId = React.useId();
|
||||
const [optionsOpen, setOptionsOpen] = React.useState(false);
|
||||
const handledOptionsRequestRef = React.useRef(0);
|
||||
const alwaysAddressShortcut = getPlatformKeysById("always-address-agent");
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -90,10 +93,17 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
}, [suggestions.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (openOptionsRequest > 0) {
|
||||
setOptionsOpen(true);
|
||||
if (openOptionsRequest <= handledOptionsRequestRef.current) return;
|
||||
handledOptionsRequestRef.current = openOptionsRequest;
|
||||
|
||||
// The first request waits for the entrance to finish. Once visible, apply
|
||||
// later requests in place so toggling a pin cannot replay that entrance.
|
||||
if (optionsOpen) {
|
||||
onOptionsRevealComplete?.(openOptionsRequest);
|
||||
return;
|
||||
}
|
||||
}, [openOptionsRequest]);
|
||||
setOptionsOpen(true);
|
||||
}, [onOptionsRevealComplete, openOptionsRequest, optionsOpen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!onDismiss) return;
|
||||
@@ -173,8 +183,14 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-72"
|
||||
data-testid="mention-options-settings"
|
||||
id={optionsId}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
onAnimationComplete={() => {
|
||||
if (openOptionsRequest > 0) {
|
||||
onOptionsRevealComplete?.(openOptionsRequest);
|
||||
}
|
||||
}}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
>
|
||||
<div className="flex min-h-12 items-center justify-between gap-3 px-3 py-2">
|
||||
@@ -390,7 +406,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
<TooltipTrigger asChild>
|
||||
<span className="absolute right-3 top-1/2 inline-flex -translate-y-1/2">
|
||||
<Toggle
|
||||
aria-label={`${isAlwaysAddressed ? "Stop automatically mentioning" : "Automatically mention"} ${suggestion.displayName}`}
|
||||
aria-label={`${isAlwaysAddressed ? "Don't automatically mention" : "Automatically mention"} ${suggestion.displayName}${isAlwaysAddressed ? " in this conversation" : ""}`}
|
||||
className="h-6 w-6 p-0 data-[state=on]:bg-primary/15 data-[state=on]:text-primary"
|
||||
data-always-address-pubkey={suggestion.pubkey?.toLowerCase()}
|
||||
data-testid={`mention-always-address-${suggestion.pubkey}`}
|
||||
@@ -406,7 +422,12 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
size="xs"
|
||||
type="button"
|
||||
>
|
||||
<AtSign aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
<Pin
|
||||
aria-hidden="true"
|
||||
className="h-3.5 w-3.5"
|
||||
data-testid="mention-auto-pin-icon"
|
||||
fill={isAlwaysAddressed ? "currentColor" : "none"}
|
||||
/>
|
||||
</Toggle>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -416,11 +437,11 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
>
|
||||
<span>
|
||||
{isAlwaysAddressed
|
||||
? "Stop automatically mentioning"
|
||||
? "Don't automatically mention in this conversation"
|
||||
: "Automatically mention"}
|
||||
</span>
|
||||
{alwaysAddressShortcut ? (
|
||||
<kbd className="flex items-center gap-0.5 rounded border border-primary-foreground/20 bg-primary-foreground/10 px-1 py-0 font-mono text-sm text-primary-foreground/70">
|
||||
<kbd className="flex items-center gap-0.5 rounded border border-secondary-foreground/20 bg-secondary-foreground/10 px-1 py-0 font-mono text-sm text-secondary-foreground">
|
||||
{(alwaysAddressShortcut.includes("+")
|
||||
? alwaysAddressShortcut.split("+")
|
||||
: Array.from(alwaysAddressShortcut)
|
||||
|
||||
@@ -64,9 +64,11 @@ import { useComposerAttachmentSpoilers } from "./useComposerAttachmentSpoilers";
|
||||
import { useComposerContentState } from "./useComposerContentState";
|
||||
import { useComposerPasteHandler } from "./useComposerPasteHandler";
|
||||
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
|
||||
import { useImplicitAgentMentionProvenance } from "./useImplicitAgentMentionProvenance";
|
||||
import { submitMessageEdit } from "./submitMessageEdit";
|
||||
import { prepareBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore";
|
||||
import { useComposerLinkPreviews } from "./useComposerLinkPreviews";
|
||||
import { useAddressedAgentMentionRestore } from "./useAddressedAgentMentionRestore";
|
||||
import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit";
|
||||
import type { MessageComposerProps } from "./MessageComposer.types";
|
||||
function MessageComposerImpl({
|
||||
@@ -131,10 +133,13 @@ function MessageComposerImpl({
|
||||
? getPersistentAgentAudienceScope({
|
||||
ownerPubkey,
|
||||
channelId,
|
||||
composerKey: effectiveDraftKey,
|
||||
})
|
||||
: null;
|
||||
const effectiveDraftKeyRef = React.useRef(effectiveDraftKey);
|
||||
effectiveDraftKeyRef.current = effectiveDraftKey;
|
||||
const implicitAgentMentionProvenance =
|
||||
useImplicitAgentMentionProvenance(effectiveDraftKey);
|
||||
const preEditSnapshotRef = React.useRef<{
|
||||
content: string;
|
||||
pendingImeta: ImetaMedia[];
|
||||
@@ -204,6 +209,7 @@ function MessageComposerImpl({
|
||||
setSpoileredAttachmentUrls,
|
||||
spoileredAttachmentUrlsRef,
|
||||
syncComposerContentFromEditor,
|
||||
getImplicitAgentMentionPrefix: implicitAgentMentionProvenance.getPrefix,
|
||||
});
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
|
||||
React.useEffect(() => {
|
||||
@@ -310,10 +316,13 @@ function MessageComposerImpl({
|
||||
const keepMentionedAgentsPinned = useKeepMentionedAgentsPinned();
|
||||
const addressPulse = useAddressMentionPulse();
|
||||
const {
|
||||
completeOptionsReveal: completeMentionOptionsReveal,
|
||||
confirmationTitle: autoPinConfirmationTitle,
|
||||
dismissConfirmation: dismissAutoPinConfirmation,
|
||||
openOptionsRequest: openMentionOptionsRequest,
|
||||
promoteExplicitlyAddressedAgents,
|
||||
promoteMentionedAgents,
|
||||
setConfirmationHovered: setAutoPinConfirmationHovered,
|
||||
turnOffConfirmation: turnOffAutoPinConfirmation,
|
||||
} = useAutoPinMentionedAgents({
|
||||
audienceScope,
|
||||
@@ -321,26 +330,13 @@ function MessageComposerImpl({
|
||||
getDisplayName: mentions.getMentionDisplayName,
|
||||
onPulse: addressPulse.pulseOne,
|
||||
onTurnOff: () => setKeepMentionedAgentsPinned(false),
|
||||
onTurnOn: () => setKeepMentionedAgentsPinned(true),
|
||||
});
|
||||
const addressedMentionRestore = useAddressedAgentMentionRestore({
|
||||
audiencePubkeys: persistentAudience.pubkeys,
|
||||
channelId,
|
||||
enabled: keepMentionedAgentsPinned,
|
||||
});
|
||||
const restoreAddressedAgentMentionsRef = React.useRef<
|
||||
(
|
||||
pubkeys?: readonly string[],
|
||||
allowedUnpinnedPubkeys?: readonly string[],
|
||||
) => string
|
||||
>(() => "");
|
||||
const restoreAddressedAgentMentionsFrameRef = React.useRef<number | null>(
|
||||
null,
|
||||
);
|
||||
const channelIdRef = React.useRef(channelId);
|
||||
channelIdRef.current = channelId;
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (restoreAddressedAgentMentionsFrameRef.current !== null) {
|
||||
cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const mentionSendFlow = useMentionSendFlow({
|
||||
channelId,
|
||||
channelLinks,
|
||||
@@ -350,23 +346,11 @@ function MessageComposerImpl({
|
||||
drafts,
|
||||
emojiAutocomplete,
|
||||
mentions,
|
||||
onAddressedAgentsComposerCleared: (pubkeys) =>
|
||||
restoreAddressedAgentMentionsRef.current(pubkeys),
|
||||
onAddressedAgentsComposerCleared:
|
||||
addressedMentionRestore.onAddressedAgentsComposerCleared,
|
||||
onAddressedAgentsSendFailed: addressPulse.shakeMany,
|
||||
onAddressedAgentsSendSucceeded: (pubkeys, newlyPinnedPubkeys) => {
|
||||
if (!keepMentionedAgentsPinned || newlyPinnedPubkeys.length === 0) return;
|
||||
const sentChannelId = channelId;
|
||||
if (restoreAddressedAgentMentionsFrameRef.current !== null) {
|
||||
cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current);
|
||||
}
|
||||
restoreAddressedAgentMentionsFrameRef.current = requestAnimationFrame(
|
||||
() => {
|
||||
restoreAddressedAgentMentionsFrameRef.current = null;
|
||||
if (channelIdRef.current !== sentChannelId) return;
|
||||
restoreAddressedAgentMentionsRef.current(pubkeys, newlyPinnedPubkeys);
|
||||
},
|
||||
);
|
||||
},
|
||||
onAddressedAgentsSendSucceeded:
|
||||
addressedMentionRestore.onAddressedAgentsSendSucceeded,
|
||||
onPrepareSendChannel,
|
||||
onSendRef,
|
||||
richText,
|
||||
@@ -445,6 +429,7 @@ function MessageComposerImpl({
|
||||
edit.insertText,
|
||||
edit.customEmojiShortcode,
|
||||
edit.preserveSelection,
|
||||
edit.reassertMentionCaret,
|
||||
);
|
||||
},
|
||||
[richText.replacePlainTextRange],
|
||||
@@ -467,16 +452,24 @@ function MessageComposerImpl({
|
||||
promoteExplicitlyAddressedAgents({
|
||||
pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [],
|
||||
}),
|
||||
onAutoPinAgentMention: (suggestion) => {
|
||||
onAutoPinAgentMention: (suggestion, options) => {
|
||||
promoteMentionedAgents({
|
||||
...options,
|
||||
pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [],
|
||||
});
|
||||
},
|
||||
onImplicitPrefixInserted: implicitAgentMentionProvenance.add,
|
||||
onImplicitPrefixRemoved: implicitAgentMentionProvenance.remove,
|
||||
onPulseAddressLock: addressPulse.pulseOne,
|
||||
profiles,
|
||||
richText,
|
||||
});
|
||||
restoreAddressedAgentMentionsRef.current = restoreAddressedAgentMentions;
|
||||
addressedMentionRestore.restoreAddressedAgentMentionsRef.current =
|
||||
restoreAddressedAgentMentions;
|
||||
React.useLayoutEffect(() => {
|
||||
if (!audienceScope || editTarget != null) return;
|
||||
restoreAddressedAgentMentions();
|
||||
}, [audienceScope, editTarget, restoreAddressedAgentMentions]);
|
||||
syncAddressedAgentsFromTextRef.current = syncAddressedAgentsFromText;
|
||||
const applyChannelInsert = React.useCallback(
|
||||
(suggestion: ChannelSuggestion) => {
|
||||
@@ -536,21 +529,17 @@ function MessageComposerImpl({
|
||||
},
|
||||
[richText.editor, mentions.clearMentions, customEmoji],
|
||||
);
|
||||
const openMentionPicker = useComposerMentionPicker({
|
||||
const mentionPicker = useComposerMentionPicker({
|
||||
mentions,
|
||||
onTurnOffAutoPinConfirmation: turnOffAutoPinConfirmation,
|
||||
richText,
|
||||
setIsEmojiPickerOpen,
|
||||
});
|
||||
const openMentionSettings = React.useCallback(
|
||||
() => openMentionPicker(false),
|
||||
[openMentionPicker],
|
||||
);
|
||||
const handleAlwaysAddressShortcut = useAlwaysAddressShortcut({
|
||||
enabled: Boolean(audienceScope && editTarget == null),
|
||||
lockedAgent: lockedAgents[0],
|
||||
mentions,
|
||||
onOpenPicker: openMentionPicker,
|
||||
onSelect: selectMentionSuggestion,
|
||||
onOpenPicker: mentionPicker.openMentionPicker,
|
||||
onToggle: toggleAlwaysAddressAgent,
|
||||
});
|
||||
const submitMessage = React.useCallback(async () => {
|
||||
@@ -872,11 +861,13 @@ function MessageComposerImpl({
|
||||
<MentionAutocomplete
|
||||
keepMentionedAgentsPinned={keepMentionedAgentsPinned}
|
||||
lockedAgentPubkeys={lockedAgentPubkeys}
|
||||
openOptionsRequest={openMentionOptionsRequest}
|
||||
onKeepMentionedAgentsPinnedChange={
|
||||
audienceScope && editTarget == null
|
||||
? setKeepMentionedAgentsPinned
|
||||
: undefined
|
||||
}
|
||||
onOptionsRevealComplete={completeMentionOptionsReveal}
|
||||
onToggleAlwaysAddressAgent={
|
||||
audienceScope && editTarget == null
|
||||
? toggleAlwaysAddressAgent
|
||||
@@ -944,7 +935,9 @@ function MessageComposerImpl({
|
||||
<EditorContent editor={richText.editor} />
|
||||
</div>
|
||||
<ComposerDockToolbar
|
||||
addressedAgents={editTarget == null ? lockedAgents : []}
|
||||
addressedAgents={
|
||||
editTarget == null && !composerDisabled ? lockedAgents : []
|
||||
}
|
||||
autoPinConfirmationTitle={autoPinConfirmationTitle}
|
||||
layoutMode={layoutMode}
|
||||
composerDisabled={composerDisabled}
|
||||
@@ -958,12 +951,13 @@ function MessageComposerImpl({
|
||||
isUploading={media.isUploading}
|
||||
onCaptureSelection={handleCaptureSelection}
|
||||
onAutoPinConfirmationDismiss={dismissAutoPinConfirmation}
|
||||
onAutoPinConfirmationTurnOff={turnOffAutoPinConfirmation}
|
||||
onAutoPinConfirmationHoverChange={setAutoPinConfirmationHovered}
|
||||
onAutoPinConfirmationTurnOff={mentionPicker.turnOff}
|
||||
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
|
||||
onEmojiSelect={insertEmoji}
|
||||
onFormattingToggle={handleFormattingToggle}
|
||||
onLinkButton={linkEditor.openFromToolbar}
|
||||
onOpenMentionPicker={openMentionSettings}
|
||||
onOpenMentionPicker={mentionPicker.openMentionSettings}
|
||||
onPaperclip={handlePaperclipClick}
|
||||
onRemoveAddressedAgent={removeAddressedAgent}
|
||||
pulseVersionByPubkey={addressPulse.pulseVersionByPubkey}
|
||||
|
||||
@@ -39,6 +39,7 @@ export const MessageComposerToolbar = React.memo(
|
||||
isUploading,
|
||||
onCaptureSelection,
|
||||
onAutoPinConfirmationDismiss,
|
||||
onAutoPinConfirmationHoverChange,
|
||||
onAutoPinConfirmationTurnOff,
|
||||
onEmojiPickerOpenChange,
|
||||
onEmojiSelect,
|
||||
@@ -64,6 +65,7 @@ export const MessageComposerToolbar = React.memo(
|
||||
isUploading: boolean;
|
||||
onCaptureSelection: () => void;
|
||||
onAutoPinConfirmationDismiss?: () => void;
|
||||
onAutoPinConfirmationHoverChange?: (hovered: boolean) => void;
|
||||
onAutoPinConfirmationTurnOff?: () => void;
|
||||
onEmojiPickerOpenChange: (open: boolean) => void;
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
@@ -187,6 +189,7 @@ export const MessageComposerToolbar = React.memo(
|
||||
confirmationTitle={autoPinConfirmationTitle}
|
||||
disabled={composerDisabled}
|
||||
onConfirmationDismiss={onAutoPinConfirmationDismiss}
|
||||
onConfirmationHoverChange={onAutoPinConfirmationHoverChange}
|
||||
onConfirmationTurnOff={onAutoPinConfirmationTurnOff}
|
||||
onCaptureSelection={onCaptureSelection}
|
||||
onOpen={onOpenMentionPicker}
|
||||
|
||||
@@ -44,13 +44,12 @@ test("agent picker preference skips people", async () => {
|
||||
assert.equal(view.result.current.mentionSelectedIndex, 1);
|
||||
});
|
||||
|
||||
test("primary+Shift+M addresses the default agent or toggles the tray selection", async () => {
|
||||
test("primary+Shift+M addresses the default agent or toggles the tray selection in place", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAlwaysAddressShortcut } = await import(
|
||||
"./useAlwaysAddressShortcut.ts"
|
||||
);
|
||||
const { isMacPlatform } = await import("@/shared/lib/platform");
|
||||
const selected = [];
|
||||
const toggled = [];
|
||||
const suggestion = {
|
||||
displayName: "Agent Ada",
|
||||
@@ -78,7 +77,6 @@ test("primary+Shift+M addresses the default agent or toggles the tray selection"
|
||||
suggestions: [suggestion],
|
||||
},
|
||||
onOpenPicker: () => {},
|
||||
onSelect: (value) => selected.push(value),
|
||||
onToggle: (value) => toggled.push(value),
|
||||
}),
|
||||
{ initialProps: { isMentionOpen: false } },
|
||||
@@ -86,16 +84,13 @@ test("primary+Shift+M addresses the default agent or toggles the tray selection"
|
||||
|
||||
act(() => assert.equal(view.result.current(createEvent()), true));
|
||||
assert.deepEqual(toggled, [suggestion]);
|
||||
assert.deepEqual(selected, []);
|
||||
|
||||
view.rerender({ isMentionOpen: true });
|
||||
act(() => assert.equal(view.result.current(createEvent()), true));
|
||||
assert.deepEqual(toggled, [suggestion]);
|
||||
assert.deepEqual(selected, [suggestion]);
|
||||
assert.deepEqual(toggled, [suggestion, suggestion]);
|
||||
|
||||
act(() => assert.equal(view.result.current(createEvent()), true));
|
||||
assert.deepEqual(toggled, [suggestion]);
|
||||
assert.deepEqual(selected, [suggestion, suggestion]);
|
||||
assert.deepEqual(toggled, [suggestion, suggestion, suggestion]);
|
||||
});
|
||||
|
||||
test("primary+Shift+M removes the current locked agent before choosing a new default", async () => {
|
||||
@@ -126,7 +121,6 @@ test("primary+Shift+M removes the current locked agent before choosing a new def
|
||||
suggestions: [],
|
||||
},
|
||||
onOpenPicker: () => {},
|
||||
onSelect: () => {},
|
||||
onToggle: (value) => toggled.push(value),
|
||||
}),
|
||||
);
|
||||
@@ -137,7 +131,7 @@ test("primary+Shift+M removes the current locked agent before choosing a new def
|
||||
altKey: false,
|
||||
code: "KeyM",
|
||||
ctrlKey: !isMacPlatform(),
|
||||
key: "m",
|
||||
key: "M",
|
||||
metaKey: isMacPlatform(),
|
||||
preventDefault() {},
|
||||
repeat: false,
|
||||
@@ -169,7 +163,6 @@ test("primary+Shift+M opens the picker when no default agent is ready", async ()
|
||||
onOpenPicker: () => {
|
||||
opened += 1;
|
||||
},
|
||||
onSelect: () => {},
|
||||
onToggle: () => {},
|
||||
}),
|
||||
);
|
||||
@@ -180,7 +173,7 @@ test("primary+Shift+M opens the picker when no default agent is ready", async ()
|
||||
altKey: false,
|
||||
code: "KeyM",
|
||||
ctrlKey: !isMacPlatform(),
|
||||
key: "m",
|
||||
key: "M",
|
||||
metaKey: isMacPlatform(),
|
||||
preventDefault() {},
|
||||
repeat: false,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from "react";
|
||||
|
||||
type RestoreAddressedAgentMentions = (
|
||||
pubkeys?: readonly string[],
|
||||
allowedUnpinnedPubkeys?: readonly string[],
|
||||
) => string;
|
||||
|
||||
export function useAddressedAgentMentionRestore({
|
||||
audiencePubkeys,
|
||||
channelId,
|
||||
enabled,
|
||||
}: {
|
||||
audiencePubkeys: readonly string[];
|
||||
channelId: string | null;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const restoreAddressedAgentMentionsRef =
|
||||
React.useRef<RestoreAddressedAgentMentions>(() => "");
|
||||
const restoreFrameRef = React.useRef<number | null>(null);
|
||||
const channelIdRef = React.useRef(channelId);
|
||||
channelIdRef.current = channelId;
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (restoreFrameRef.current !== null) {
|
||||
cancelAnimationFrame(restoreFrameRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onAddressedAgentsComposerCleared = React.useCallback(
|
||||
(pubkeys: readonly string[]) =>
|
||||
restoreAddressedAgentMentionsRef.current(pubkeys),
|
||||
[],
|
||||
);
|
||||
const onAddressedAgentsSendSucceeded = React.useCallback(
|
||||
(pubkeys: readonly string[], newlyPinnedPubkeys: readonly string[]) => {
|
||||
const currentAudience = new Set(audiencePubkeys);
|
||||
const confirmedPinnedPubkeys = newlyPinnedPubkeys.filter((pubkey) =>
|
||||
currentAudience.has(pubkey),
|
||||
);
|
||||
if (!enabled || confirmedPinnedPubkeys.length === 0) return;
|
||||
|
||||
const sentChannelId = channelId;
|
||||
if (restoreFrameRef.current !== null) {
|
||||
cancelAnimationFrame(restoreFrameRef.current);
|
||||
}
|
||||
restoreFrameRef.current = requestAnimationFrame(() => {
|
||||
restoreFrameRef.current = null;
|
||||
if (channelIdRef.current !== sentChannelId) return;
|
||||
restoreAddressedAgentMentionsRef.current(
|
||||
pubkeys,
|
||||
confirmedPinnedPubkeys,
|
||||
);
|
||||
});
|
||||
},
|
||||
[audiencePubkeys, channelId, enabled],
|
||||
);
|
||||
|
||||
return {
|
||||
onAddressedAgentsComposerCleared,
|
||||
onAddressedAgentsSendSucceeded,
|
||||
restoreAddressedAgentMentionsRef,
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,7 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds
|
||||
);
|
||||
const appliedEdits = [];
|
||||
const addedPubkeys = [];
|
||||
const openPickerCalls = [];
|
||||
const pulsedPubkeys = [];
|
||||
let cancelCount = 0;
|
||||
const text = "@";
|
||||
@@ -47,6 +48,7 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds
|
||||
getMentionDisplayName: () => "Agent Ada",
|
||||
isInlineMentionSelection: () => false,
|
||||
isMentionOpen: true,
|
||||
openMentionPicker: (...args) => openPickerCalls.push(args),
|
||||
registerMentionPubkey: () => {},
|
||||
mentionStartIndex: text.lastIndexOf("@"),
|
||||
};
|
||||
@@ -82,9 +84,11 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds
|
||||
replaceToOffset: 0,
|
||||
insertText: "@Agent Ada ",
|
||||
preserveSelection: true,
|
||||
reassertMentionCaret: false,
|
||||
},
|
||||
]);
|
||||
assert.equal(cancelCount, 0);
|
||||
assert.deepEqual(openPickerCalls, [[text.length, "preserve"]]);
|
||||
assert.deepEqual(addedPubkeys, ["agent-pubkey"]);
|
||||
assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]);
|
||||
assert.equal(
|
||||
@@ -301,8 +305,8 @@ test("selecting an agent from a typed query immediately auto-addresses it", asyn
|
||||
audience,
|
||||
audienceScope: "channel-scope",
|
||||
mentions,
|
||||
onAutoPinAgentMention: (suggestion) =>
|
||||
autoPinnedSuggestions.push(suggestion),
|
||||
onAutoPinAgentMention: (suggestion, options) =>
|
||||
autoPinnedSuggestions.push([suggestion, options]),
|
||||
onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey),
|
||||
richText,
|
||||
}),
|
||||
@@ -322,7 +326,9 @@ test("selecting an agent from a typed query immediately auto-addresses it", asyn
|
||||
insertText: "@Agent Ada ",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(autoPinnedSuggestions, [suggestion]);
|
||||
assert.deepEqual(autoPinnedSuggestions, [
|
||||
[suggestion, { reinstateExcluded: true }],
|
||||
]);
|
||||
assert.deepEqual(addedPubkeys, []);
|
||||
assert.deepEqual(pulsedPubkeys, []);
|
||||
assert.equal(result.current.announcement, "");
|
||||
@@ -375,11 +381,149 @@ test("selecting a human mention never changes automatic addressing", async () =>
|
||||
assert.deepEqual(autoPinnedSuggestions, []);
|
||||
});
|
||||
|
||||
test("removing the last agent chip clears its automatic address", async () => {
|
||||
test("restoring a multi-word automatic mention into an empty composer focuses after its trailing space", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const appliedEdits = [];
|
||||
const registeredMentions = [];
|
||||
let focusEndCount = 0;
|
||||
const { result } = renderHook(() =>
|
||||
useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit: (edit) => appliedEdits.push(edit),
|
||||
audience: {
|
||||
pubkeys: ["agent-pubkey"],
|
||||
addPubkey: () => {},
|
||||
},
|
||||
audienceScope: "thread-scope",
|
||||
mentions: {
|
||||
getDraftMentionRefs: () => [],
|
||||
getMentionDisplayName: () => "claude code",
|
||||
registerMentionPubkey: (...args) => registeredMentions.push(args),
|
||||
},
|
||||
onPulseAddressLock: () => {},
|
||||
richText: {
|
||||
focusEnd: () => {
|
||||
focusEndCount += 1;
|
||||
},
|
||||
getPlainTextAndCursor: () => ({ text: "", cursor: 0 }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => result.current.restoreAddressedAgentMentions());
|
||||
|
||||
assert.deepEqual(registeredMentions, [
|
||||
["claude code", "agent-pubkey", { isAgent: true }],
|
||||
]);
|
||||
assert.deepEqual(appliedEdits, [
|
||||
{
|
||||
replaceFromOffset: 0,
|
||||
replaceToOffset: 0,
|
||||
insertText: "@claude code ",
|
||||
preserveSelection: true,
|
||||
},
|
||||
]);
|
||||
assert.equal(focusEndCount, 1);
|
||||
});
|
||||
|
||||
test("restoring before authored text preserves its selection", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const appliedEdits = [];
|
||||
let focusEndCount = 0;
|
||||
const { result } = renderHook(() =>
|
||||
useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit: (edit) => appliedEdits.push(edit),
|
||||
audience: {
|
||||
pubkeys: ["agent-pubkey"],
|
||||
addPubkey: () => {},
|
||||
},
|
||||
audienceScope: "thread-scope",
|
||||
mentions: {
|
||||
getDraftMentionRefs: () => [],
|
||||
getMentionDisplayName: () => "Morgarita",
|
||||
registerMentionPubkey: () => {},
|
||||
},
|
||||
onPulseAddressLock: () => {},
|
||||
richText: {
|
||||
focusEnd: () => {
|
||||
focusEndCount += 1;
|
||||
},
|
||||
getPlainTextAndCursor: () => ({ text: "draft text", cursor: 10 }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => result.current.restoreAddressedAgentMentions());
|
||||
|
||||
assert.deepEqual(appliedEdits, [
|
||||
{
|
||||
replaceFromOffset: 0,
|
||||
replaceToOffset: 0,
|
||||
insertText: "@Morgarita ",
|
||||
preserveSelection: true,
|
||||
},
|
||||
]);
|
||||
assert.equal(focusEndCount, 0);
|
||||
});
|
||||
|
||||
test("restoring an existing automatic mention re-registers its agent chip", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const appliedEdits = [];
|
||||
const registeredMentions = [];
|
||||
const syncedAddressedNames = [];
|
||||
let focusEndCount = 0;
|
||||
const { result } = renderHook(() =>
|
||||
useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit: (edit) => appliedEdits.push(edit),
|
||||
audience: {
|
||||
pubkeys: ["agent-pubkey"],
|
||||
addPubkey: () => {},
|
||||
},
|
||||
audienceScope: "thread-scope",
|
||||
mentions: {
|
||||
getDraftMentionRefs: () => [],
|
||||
getMentionDisplayName: () => "claude code",
|
||||
registerMentionPubkey: (...args) => registeredMentions.push(args),
|
||||
},
|
||||
onPulseAddressLock: () => {},
|
||||
richText: {
|
||||
focusEnd: () => {
|
||||
focusEndCount += 1;
|
||||
},
|
||||
getPlainTextAndCursor: () => ({
|
||||
text: "@claude code ",
|
||||
cursor: 13,
|
||||
}),
|
||||
syncAddressedAgentMentionNames: (names) =>
|
||||
syncedAddressedNames.push(names),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => result.current.restoreAddressedAgentMentions());
|
||||
|
||||
assert.deepEqual(registeredMentions, [
|
||||
["claude code", "agent-pubkey", { isAgent: true }],
|
||||
]);
|
||||
assert.deepEqual(appliedEdits, []);
|
||||
assert.equal(focusEndCount, 0);
|
||||
assert.deepEqual(syncedAddressedNames.at(-1), ["claude code"]);
|
||||
});
|
||||
|
||||
test("deleting the last automatic agent mention explicitly excludes its address", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const excludedPubkeys = [];
|
||||
const removedPubkeys = [];
|
||||
const mentionRefsByText = {
|
||||
"@Agent Ada first @Agent Ada second": [
|
||||
@@ -396,6 +540,7 @@ test("removing the last agent chip clears its automatic address", async () => {
|
||||
applyAutocompleteEdit: () => {},
|
||||
audience: {
|
||||
pubkeys: ["agent-pubkey", "existing-lock"],
|
||||
excludePubkey: (pubkey) => excludedPubkeys.push(pubkey),
|
||||
removePubkey: (pubkey) => removedPubkeys.push(pubkey),
|
||||
},
|
||||
audienceScope: "channel-scope",
|
||||
@@ -418,20 +563,23 @@ test("removing the last agent chip clears its automatic address", async () => {
|
||||
assert.deepEqual(removedPubkeys, []);
|
||||
|
||||
act(() => result.current.syncAddressedAgentsFromText(""));
|
||||
assert.deepEqual(removedPubkeys, ["agent-pubkey"]);
|
||||
assert.deepEqual(excludedPubkeys, ["agent-pubkey"]);
|
||||
assert.deepEqual(removedPubkeys, []);
|
||||
});
|
||||
|
||||
test("removing human mentions is ignored while removing a restored agent chip clears its lock", async () => {
|
||||
test("deleting human mentions is ignored while deleting a restored automatic agent mention excludes its address", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const excludedPubkeys = [];
|
||||
const removedPubkeys = [];
|
||||
const { result } = renderHook(() =>
|
||||
useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit: () => {},
|
||||
audience: {
|
||||
pubkeys: ["existing-lock"],
|
||||
excludePubkey: (pubkey) => excludedPubkeys.push(pubkey),
|
||||
removePubkey: (pubkey) => removedPubkeys.push(pubkey),
|
||||
},
|
||||
audienceScope: "channel-scope",
|
||||
@@ -462,9 +610,11 @@ test("removing human mentions is ignored while removing a restored agent chip cl
|
||||
result.current.syncAddressedAgentsFromText("@Alice @Existing Agent"),
|
||||
);
|
||||
act(() => result.current.syncAddressedAgentsFromText("@Alice"));
|
||||
assert.deepEqual(removedPubkeys, ["existing-lock"]);
|
||||
assert.deepEqual(excludedPubkeys, ["existing-lock"]);
|
||||
assert.deepEqual(removedPubkeys, []);
|
||||
act(() => result.current.syncAddressedAgentsFromText(""));
|
||||
assert.deepEqual(removedPubkeys, ["existing-lock"]);
|
||||
assert.deepEqual(excludedPubkeys, ["existing-lock"]);
|
||||
assert.deepEqual(removedPubkeys, []);
|
||||
});
|
||||
|
||||
test("selecting an agent from the explicit picker auto-addresses it", async () => {
|
||||
@@ -529,13 +679,14 @@ test("selecting an agent from the explicit picker auto-addresses it", async () =
|
||||
);
|
||||
});
|
||||
|
||||
test("selecting an explicitly unpinned agent inserts a mention until send", async () => {
|
||||
test("repeatedly selecting an explicitly unpinned agent keeps its mentions manual", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const appliedEdits = [];
|
||||
const addedPubkeys = [];
|
||||
const autoPinnedSuggestions = [];
|
||||
const removedPubkeys = [];
|
||||
const pulsedPubkeys = [];
|
||||
const mentions = {
|
||||
@@ -545,7 +696,7 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn
|
||||
],
|
||||
getMentionDisplayName: () => "Agent Ada",
|
||||
registerMentionPubkey: () => {},
|
||||
isInlineMentionSelection: () => false,
|
||||
isInlineMentionSelection: () => true,
|
||||
insertMention: () => ({
|
||||
replaceFromOffset: 0,
|
||||
replaceToOffset: 0,
|
||||
@@ -570,6 +721,8 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn
|
||||
},
|
||||
audienceScope: "channel-scope",
|
||||
mentions,
|
||||
onAutoPinAgentMention: (suggestion, options) =>
|
||||
autoPinnedSuggestions.push([suggestion, options]),
|
||||
onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey),
|
||||
richText,
|
||||
}),
|
||||
@@ -577,7 +730,14 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn
|
||||
);
|
||||
|
||||
act(() => result.current.removeAddressedAgent("AGENT-PUBKEY"));
|
||||
assert.deepEqual(appliedEdits, []);
|
||||
assert.deepEqual(appliedEdits, [
|
||||
{
|
||||
replaceFromOffset: 0,
|
||||
replaceToOffset: 11,
|
||||
insertText: "",
|
||||
},
|
||||
]);
|
||||
appliedEdits.length = 0;
|
||||
rerender({ pubkeys: [] });
|
||||
act(() => {
|
||||
result.current.selectMentionSuggestion({
|
||||
@@ -586,6 +746,13 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn
|
||||
isAgent: true,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
result.current.selectMentionSuggestion({
|
||||
pubkey: "agent-pubkey",
|
||||
displayName: "Agent Ada",
|
||||
isAgent: true,
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(removedPubkeys, ["agent-pubkey"]);
|
||||
assert.deepEqual(appliedEdits, [
|
||||
@@ -594,11 +761,77 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn
|
||||
replaceToOffset: 0,
|
||||
insertText: "@Agent Ada ",
|
||||
},
|
||||
{
|
||||
replaceFromOffset: 0,
|
||||
replaceToOffset: 0,
|
||||
insertText: "@Agent Ada ",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(addedPubkeys, []);
|
||||
assert.deepEqual(autoPinnedSuggestions, [
|
||||
[
|
||||
{
|
||||
pubkey: "agent-pubkey",
|
||||
displayName: "Agent Ada",
|
||||
isAgent: true,
|
||||
},
|
||||
{ reinstateExcluded: false },
|
||||
],
|
||||
[
|
||||
{
|
||||
pubkey: "agent-pubkey",
|
||||
displayName: "Agent Ada",
|
||||
isAgent: true,
|
||||
},
|
||||
{ reinstateExcluded: false },
|
||||
],
|
||||
]);
|
||||
assert.deepEqual(pulsedPubkeys, []);
|
||||
});
|
||||
|
||||
test("restoring after an agent rename keeps the existing automatic mention", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const appliedEdits = [];
|
||||
const registeredMentions = [];
|
||||
const oldName = "OldName";
|
||||
const newName = "NewName";
|
||||
const { result, rerender } = renderHook(
|
||||
({ displayName }) =>
|
||||
useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit: (edit) => appliedEdits.push(edit),
|
||||
audience: { pubkeys: ["agent-pubkey"], addPubkey: () => {} },
|
||||
audienceScope: "channel-scope",
|
||||
mentions: {
|
||||
getDraftMentionRefs: () => [
|
||||
{ displayName: oldName, pubkey: "agent-pubkey", isAgent: true },
|
||||
],
|
||||
getMentionDisplayName: () => displayName,
|
||||
registerMentionPubkey: (...args) => registeredMentions.push(args),
|
||||
},
|
||||
onPulseAddressLock: () => {},
|
||||
profiles: {},
|
||||
richText: {
|
||||
getPlainTextAndCursor: () => ({
|
||||
text: `@${oldName} authored draft`,
|
||||
cursor: 23,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
{ initialProps: { displayName: oldName } },
|
||||
);
|
||||
|
||||
rerender({ displayName: newName });
|
||||
act(() => result.current.restoreAddressedAgentMentions());
|
||||
|
||||
assert.deepEqual(appliedEdits, []);
|
||||
assert.deepEqual(registeredMentions, [
|
||||
[newName, "agent-pubkey", { isAgent: true }],
|
||||
]);
|
||||
});
|
||||
|
||||
test("an addressed agent keeps its resolved name while mention state clears during send", async () => {
|
||||
const { renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { getMentionOffsets } from "@/features/messages/lib/hasMention";
|
||||
import { stripImplicitAgentMentionPrefix } from "@/features/messages/lib/stripImplicitAgentMentions";
|
||||
import type { usePersistentAgentAudience } from "@/features/messages/lib/persistentAgentAudience";
|
||||
import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
|
||||
import type {
|
||||
@@ -59,6 +60,8 @@ export function useAgentAddressLockPicker({
|
||||
mentions,
|
||||
onAddressAgentMention,
|
||||
onAutoPinAgentMention,
|
||||
onImplicitPrefixInserted,
|
||||
onImplicitPrefixRemoved,
|
||||
onPulseAddressLock,
|
||||
profiles,
|
||||
richText,
|
||||
@@ -68,7 +71,16 @@ export function useAgentAddressLockPicker({
|
||||
audienceScope: string | null;
|
||||
mentions: UseMentionsResult;
|
||||
onAddressAgentMention?: (suggestion: MentionSuggestion) => void;
|
||||
onAutoPinAgentMention?: (suggestion: MentionSuggestion) => void;
|
||||
onAutoPinAgentMention?: (
|
||||
suggestion: MentionSuggestion,
|
||||
options: { reinstateExcluded: boolean },
|
||||
) => void;
|
||||
/** Records generated mention provenance at the insertion boundary. */
|
||||
onImplicitPrefixInserted?: (
|
||||
mentions: readonly { pubkey: string; prefix: string }[],
|
||||
) => void;
|
||||
/** Removes generated mention provenance by its stable identity. */
|
||||
onImplicitPrefixRemoved?: (pubkey: string) => void;
|
||||
onPulseAddressLock: (pubkey: string) => void;
|
||||
profiles?: UserProfileLookup;
|
||||
richText: UseRichTextEditorResult;
|
||||
@@ -83,6 +95,11 @@ export function useAgentAddressLockPicker({
|
||||
unpinnedAudienceScopeRef.current = audienceScope;
|
||||
unpinnedAgentPubkeysRef.current.clear();
|
||||
}
|
||||
React.useEffect(() => {
|
||||
for (const pubkey of lockedAgentPubkeys) {
|
||||
unpinnedAgentPubkeysRef.current.delete(pubkey);
|
||||
}
|
||||
}, [lockedAgentPubkeys]);
|
||||
const lockedAgentNamesRef = React.useRef(new Map<string, string>());
|
||||
const visibleAgentMentionPubkeysRef = React.useRef(new Set<string>());
|
||||
const mentionSyncScopeRef = React.useRef(audienceScope);
|
||||
@@ -115,6 +132,11 @@ export function useAgentAddressLockPicker({
|
||||
}),
|
||||
[audience.pubkeys, mentions.getMentionDisplayName, profiles],
|
||||
);
|
||||
React.useLayoutEffect(() => {
|
||||
richText.syncAddressedAgentMentionNames?.(
|
||||
lockedAgents.map((agent) => agent.displayName),
|
||||
);
|
||||
}, [lockedAgents, richText.syncAddressedAgentMentionNames]);
|
||||
const trackMentionAddressedAgent = React.useCallback(
|
||||
(pubkey: string) => {
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
@@ -138,16 +160,20 @@ export function useAgentAddressLockPicker({
|
||||
!presentAgentPubkeys.has(pubkey) &&
|
||||
lockedAgentPubkeys.has(pubkey)
|
||||
) {
|
||||
audience.removePubkey(pubkey);
|
||||
const excludePubkey = audience.excludePubkey ?? audience.removePubkey;
|
||||
onImplicitPrefixRemoved?.(pubkey);
|
||||
excludePubkey(pubkey);
|
||||
}
|
||||
}
|
||||
visibleAgentMentionPubkeysRef.current = presentAgentPubkeys;
|
||||
},
|
||||
[
|
||||
audience.excludePubkey,
|
||||
audience.removePubkey,
|
||||
audienceScope,
|
||||
lockedAgentPubkeys,
|
||||
mentions.getDraftMentionRefs,
|
||||
onImplicitPrefixRemoved,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -156,9 +182,37 @@ export function useAgentAddressLockPicker({
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
if (!audienceScope || !normalized) return;
|
||||
unpinnedAgentPubkeysRef.current.add(normalized);
|
||||
audience.removePubkey(normalized);
|
||||
const excludePubkey = audience.excludePubkey ?? audience.removePubkey;
|
||||
excludePubkey(normalized);
|
||||
const displayName = lockedAgents.find(
|
||||
(agent) => agent.pubkey === normalized,
|
||||
)?.displayName;
|
||||
if (displayName) {
|
||||
const text = richText.getPlainTextAndCursor().text;
|
||||
const implicitPrefix = `@${displayName}${text === `@${displayName}` ? "" : " "}`;
|
||||
const strippedText = stripImplicitAgentMentionPrefix(
|
||||
text,
|
||||
implicitPrefix,
|
||||
);
|
||||
if (strippedText !== text) {
|
||||
onImplicitPrefixRemoved?.(normalized);
|
||||
applyAutocompleteEdit({
|
||||
replaceFromOffset: 0,
|
||||
replaceToOffset: text.length - strippedText.length,
|
||||
insertText: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[audience.removePubkey, audienceScope],
|
||||
[
|
||||
applyAutocompleteEdit,
|
||||
audience.excludePubkey,
|
||||
audience.removePubkey,
|
||||
audienceScope,
|
||||
lockedAgents,
|
||||
onImplicitPrefixRemoved,
|
||||
richText.getPlainTextAndCursor,
|
||||
],
|
||||
);
|
||||
const removeAddressedAgentMentions = React.useCallback(
|
||||
(pubkey: string) => {
|
||||
@@ -199,11 +253,14 @@ export function useAgentAddressLockPicker({
|
||||
});
|
||||
const { text } = richText.getPlainTextAndCursor();
|
||||
if (getMentionOffsets(text, suggestion.displayName).length === 0) {
|
||||
const insertedText = `@${suggestion.displayName} `;
|
||||
onImplicitPrefixInserted?.([{ pubkey, prefix: insertedText }]);
|
||||
applyAutocompleteEdit({
|
||||
replaceFromOffset: 0,
|
||||
replaceToOffset: 0,
|
||||
insertText: `@${suggestion.displayName} `,
|
||||
preserveSelection: true,
|
||||
insertText: insertedText,
|
||||
preserveSelection: text.length > 0,
|
||||
reassertMentionCaret: false,
|
||||
});
|
||||
}
|
||||
trackMentionAddressedAgent(pubkey);
|
||||
@@ -216,24 +273,31 @@ export function useAgentAddressLockPicker({
|
||||
setAnnouncement(`Automatically mentioning ${suggestion.displayName}`);
|
||||
}
|
||||
|
||||
if (mentions.isMentionOpen && mentions.isInlineMentionSelection()) {
|
||||
if (mentions.isMentionOpen) {
|
||||
const { text, cursor } = richText.getPlainTextAndCursor();
|
||||
const activeMention = detectPrefixQuery("@", text, cursor, [
|
||||
suggestion.displayName.toLowerCase(),
|
||||
]);
|
||||
const queryStart = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
activeMention?.startIndex ?? mentions.mentionStartIndex,
|
||||
text.length,
|
||||
),
|
||||
);
|
||||
applyAutocompleteEdit({
|
||||
replaceFromOffset: queryStart,
|
||||
replaceToOffset: Math.max(queryStart, Math.min(cursor, text.length)),
|
||||
insertText: "",
|
||||
});
|
||||
mentions.openMentionPicker(queryStart, "preserve");
|
||||
if (mentions.isInlineMentionSelection()) {
|
||||
const activeMention = detectPrefixQuery("@", text, cursor, [
|
||||
suggestion.displayName.toLowerCase(),
|
||||
]);
|
||||
const queryStart = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
activeMention?.startIndex ?? mentions.mentionStartIndex,
|
||||
text.length,
|
||||
),
|
||||
);
|
||||
applyAutocompleteEdit({
|
||||
replaceFromOffset: queryStart,
|
||||
replaceToOffset: Math.max(
|
||||
queryStart,
|
||||
Math.min(cursor, text.length),
|
||||
),
|
||||
insertText: "",
|
||||
});
|
||||
mentions.openMentionPicker(queryStart, "preserve");
|
||||
} else {
|
||||
mentions.openMentionPicker(cursor, "preserve");
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -247,6 +311,7 @@ export function useAgentAddressLockPicker({
|
||||
mentions.openMentionPicker,
|
||||
mentions.registerMentionPubkey,
|
||||
onAddressAgentMention,
|
||||
onImplicitPrefixInserted,
|
||||
onPulseAddressLock,
|
||||
removeAddressedAgentMentions,
|
||||
richText.getPlainTextAndCursor,
|
||||
@@ -264,9 +329,10 @@ export function useAgentAddressLockPicker({
|
||||
unpinnedAgentPubkeysRef.current.has(pubkey);
|
||||
if (mentions.isInlineMentionSelection() || wasUnpinned) {
|
||||
applyAutocompleteEdit(mentions.insertMention(suggestion, cursor));
|
||||
if (wasUnpinned) unpinnedAgentPubkeysRef.current.delete(pubkey);
|
||||
trackMentionAddressedAgent(pubkey);
|
||||
onAutoPinAgentMention?.(suggestion);
|
||||
onAutoPinAgentMention?.(suggestion, {
|
||||
reinstateExcluded: !wasUnpinned,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -335,8 +401,24 @@ export function useAgentAddressLockPicker({
|
||||
return { pubkey, displayName };
|
||||
});
|
||||
const { text } = richText.getPlainTextAndCursor();
|
||||
// A profile can rename an agent while this draft is off-screen. Mention
|
||||
// refs retain the identity of its already-inserted automatic prefix, so
|
||||
// use that identity as well as the current display name when deciding
|
||||
// whether restoration is needed.
|
||||
const presentAgentPubkeys = new Set(
|
||||
mentions
|
||||
.getDraftMentionRefs(text)
|
||||
.filter((ref) => ref.isAgent)
|
||||
.map((ref) => normalizePubkey(ref.pubkey)),
|
||||
);
|
||||
for (const agent of targetAgents) {
|
||||
if (getMentionOffsets(text, agent.displayName).length > 0) {
|
||||
if (
|
||||
presentAgentPubkeys.has(agent.pubkey) ||
|
||||
getMentionOffsets(text, agent.displayName).length > 0
|
||||
) {
|
||||
mentions.registerMentionPubkey(agent.displayName, agent.pubkey, {
|
||||
isAgent: true,
|
||||
});
|
||||
visibleAgentMentionPubkeysRef.current.add(agent.pubkey);
|
||||
}
|
||||
}
|
||||
@@ -344,6 +426,7 @@ export function useAgentAddressLockPicker({
|
||||
(agent) =>
|
||||
(!unpinnedAgentPubkeysRef.current.has(agent.pubkey) ||
|
||||
allowedUnpinned.has(agent.pubkey)) &&
|
||||
!presentAgentPubkeys.has(agent.pubkey) &&
|
||||
getMentionOffsets(text, agent.displayName).length === 0,
|
||||
);
|
||||
if (missingAgents.length === 0) return text;
|
||||
@@ -356,20 +439,34 @@ export function useAgentAddressLockPicker({
|
||||
const insertedText = `${missingAgents
|
||||
.map((agent) => `@${agent.displayName}`)
|
||||
.join(" ")} `;
|
||||
onImplicitPrefixInserted?.(
|
||||
missingAgents.map((agent) => ({
|
||||
pubkey: agent.pubkey,
|
||||
prefix: `@${agent.displayName} `,
|
||||
})),
|
||||
);
|
||||
applyAutocompleteEdit({
|
||||
replaceFromOffset: 0,
|
||||
replaceToOffset: 0,
|
||||
insertText: insertedText,
|
||||
preserveSelection: true,
|
||||
});
|
||||
// A restored empty composer has no authored caret to preserve. Move it
|
||||
// to the real document end after insertion so WebKit places it after
|
||||
// the trailing space, without routing the multi-word name back through
|
||||
// autocomplete settlement (which would lose its mention decoration).
|
||||
if (text.length === 0) richText.focusEnd();
|
||||
return `${insertedText}${text}`;
|
||||
},
|
||||
[
|
||||
applyAutocompleteEdit,
|
||||
audience.pubkeys,
|
||||
mentions.getDraftMentionRefs,
|
||||
mentions.getMentionDisplayName,
|
||||
mentions.registerMentionPubkey,
|
||||
onImplicitPrefixInserted,
|
||||
profiles,
|
||||
richText.focusEnd,
|
||||
richText.getPlainTextAndCursor,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -9,14 +9,12 @@ export function useAlwaysAddressShortcut({
|
||||
lockedAgent,
|
||||
mentions,
|
||||
onOpenPicker,
|
||||
onSelect,
|
||||
onToggle,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
lockedAgent?: Pick<MentionSuggestion, "avatarUrl" | "displayName" | "pubkey">;
|
||||
mentions: UseMentionsResult;
|
||||
onOpenPicker: (insertTrigger?: boolean) => void;
|
||||
onSelect: (suggestion: MentionSuggestion) => void;
|
||||
onToggle: (suggestion: MentionSuggestion) => void;
|
||||
}) {
|
||||
const {
|
||||
@@ -48,11 +46,7 @@ export function useAlwaysAddressShortcut({
|
||||
if (!isMentionOpen) onOpenPicker(false);
|
||||
return true;
|
||||
}
|
||||
if (isMentionOpen) {
|
||||
onSelect(suggestion);
|
||||
} else {
|
||||
onToggle(suggestion);
|
||||
}
|
||||
onToggle(suggestion);
|
||||
return true;
|
||||
},
|
||||
[
|
||||
@@ -62,7 +56,6 @@ export function useAlwaysAddressShortcut({
|
||||
lockedAgent,
|
||||
mentionSelectedIndex,
|
||||
onOpenPicker,
|
||||
onSelect,
|
||||
onToggle,
|
||||
suggestions,
|
||||
],
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getPersistentAgentAudienceRevision,
|
||||
promotePersistentAgentAudienceIfUnchanged,
|
||||
removePersistentAgentAudienceMembersIfUnchanged,
|
||||
usePersistentAgentAudience,
|
||||
} from "@/features/messages/lib/persistentAgentAudience";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
@@ -16,12 +17,19 @@ type Confirmation = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
type PendingPreferenceChange = {
|
||||
confirmation?: Confirmation;
|
||||
enabled: boolean;
|
||||
request: number;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
audienceScope: string | null;
|
||||
enabled: boolean;
|
||||
getDisplayName: (pubkey: string) => string | null | undefined;
|
||||
onPulse: (pubkey: string) => void;
|
||||
onTurnOff: () => void;
|
||||
onTurnOn: () => void;
|
||||
};
|
||||
|
||||
export function useAutoPinMentionedAgents({
|
||||
@@ -30,19 +38,101 @@ export function useAutoPinMentionedAgents({
|
||||
getDisplayName,
|
||||
onPulse,
|
||||
onTurnOff,
|
||||
onTurnOn,
|
||||
}: Options) {
|
||||
const { pubkeys: currentAudiencePubkeys } =
|
||||
usePersistentAgentAudience(audienceScope);
|
||||
const [confirmation, setConfirmation] = React.useState<Confirmation | null>(
|
||||
null,
|
||||
);
|
||||
const [confirmationHovered, setConfirmationHovered] = React.useState(false);
|
||||
const [openOptionsRequest, setOpenOptionsRequest] = React.useState(0);
|
||||
const nextOptionsRequestRef = React.useRef(0);
|
||||
const pendingPreferenceChangeRef =
|
||||
React.useRef<PendingPreferenceChange | null>(null);
|
||||
const onTurnOffRef = React.useRef(onTurnOff);
|
||||
const onTurnOnRef = React.useRef(onTurnOn);
|
||||
onTurnOffRef.current = onTurnOff;
|
||||
onTurnOnRef.current = onTurnOn;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!confirmation) return;
|
||||
if (pendingPreferenceChangeRef.current?.enabled === enabled) {
|
||||
pendingPreferenceChangeRef.current = null;
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
const pending = pendingPreferenceChangeRef.current;
|
||||
pendingPreferenceChangeRef.current = null;
|
||||
if (!pending) return;
|
||||
if (pending.enabled) {
|
||||
onTurnOnRef.current();
|
||||
} else {
|
||||
onTurnOffRef.current();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const requestPreferenceChange = React.useCallback(
|
||||
(preferenceEnabled: boolean, pendingConfirmation?: Confirmation) => {
|
||||
const request = nextOptionsRequestRef.current + 1;
|
||||
nextOptionsRequestRef.current = request;
|
||||
pendingPreferenceChangeRef.current = {
|
||||
confirmation: pendingConfirmation,
|
||||
enabled: preferenceEnabled,
|
||||
request,
|
||||
};
|
||||
setOpenOptionsRequest(request);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const completeOptionsReveal = React.useCallback((request: number) => {
|
||||
const pending = pendingPreferenceChangeRef.current;
|
||||
if (!pending || pending.request !== request) return;
|
||||
pendingPreferenceChangeRef.current = null;
|
||||
if (pending.enabled) {
|
||||
onTurnOnRef.current();
|
||||
return;
|
||||
}
|
||||
if (pending.confirmation) {
|
||||
removePersistentAgentAudienceMembersIfUnchanged({
|
||||
expectedRevision: pending.confirmation.expectedRevision,
|
||||
pubkeys: pending.confirmation.pubkeys,
|
||||
scope: pending.confirmation.scope,
|
||||
});
|
||||
}
|
||||
onTurnOffRef.current();
|
||||
}, []);
|
||||
|
||||
const clearConfirmation = React.useCallback(() => {
|
||||
setConfirmationHovered(false);
|
||||
setConfirmation(null);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!confirmation || confirmationHovered) return;
|
||||
const timeout = window.setTimeout(
|
||||
() => setConfirmation(null),
|
||||
clearConfirmation,
|
||||
CONFIRMATION_DURATION_MS,
|
||||
);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [confirmation]);
|
||||
}, [clearConfirmation, confirmation, confirmationHovered]);
|
||||
|
||||
const currentAudiencePubkeySet = React.useMemo(
|
||||
() => new Set(currentAudiencePubkeys.map(normalizePubkey).filter(Boolean)),
|
||||
[currentAudiencePubkeys],
|
||||
);
|
||||
const confirmationIsCurrent =
|
||||
confirmation?.scope === audienceScope &&
|
||||
confirmation.pubkeys.every((pubkey) =>
|
||||
currentAudiencePubkeySet.has(pubkey),
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (confirmation && !confirmationIsCurrent) clearConfirmation();
|
||||
}, [clearConfirmation, confirmation, confirmationIsCurrent]);
|
||||
|
||||
const promoteAgents = React.useCallback(
|
||||
({
|
||||
@@ -50,10 +140,12 @@ export function useAutoPinMentionedAgents({
|
||||
? getPersistentAgentAudienceRevision(audienceScope)
|
||||
: 0,
|
||||
pubkeys,
|
||||
reinstateExcluded,
|
||||
requirePreference,
|
||||
}: {
|
||||
expectedRevision?: number;
|
||||
pubkeys: readonly string[];
|
||||
reinstateExcluded: boolean;
|
||||
requirePreference: boolean;
|
||||
}) => {
|
||||
if (!audienceScope || (requirePreference && !enabled)) return;
|
||||
@@ -62,6 +154,7 @@ export function useAutoPinMentionedAgents({
|
||||
].filter(Boolean);
|
||||
const promotion = promotePersistentAgentAudienceIfUnchanged({
|
||||
expectedRevision,
|
||||
reinstateExcluded,
|
||||
pubkeys: normalizedPubkeys,
|
||||
scope: audienceScope,
|
||||
});
|
||||
@@ -78,6 +171,7 @@ export function useAutoPinMentionedAgents({
|
||||
: promotedPubkeys.length === 1
|
||||
? "Agent will be mentioned automatically"
|
||||
: `${promotedPubkeys.length} agents will be mentioned automatically`;
|
||||
setConfirmationHovered(false);
|
||||
setConfirmation({
|
||||
expectedRevision: revision,
|
||||
pubkeys: promotedPubkeys,
|
||||
@@ -88,37 +182,45 @@ export function useAutoPinMentionedAgents({
|
||||
[audienceScope, enabled, getDisplayName, onPulse],
|
||||
);
|
||||
const promoteMentionedAgents = React.useCallback(
|
||||
(promotion: { expectedRevision?: number; pubkeys: readonly string[] }) =>
|
||||
promoteAgents({ ...promotion, requirePreference: true }),
|
||||
(promotion: {
|
||||
expectedRevision?: number;
|
||||
pubkeys: readonly string[];
|
||||
reinstateExcluded?: boolean;
|
||||
}) =>
|
||||
promoteAgents({
|
||||
...promotion,
|
||||
reinstateExcluded: promotion.reinstateExcluded ?? false,
|
||||
requirePreference: true,
|
||||
}),
|
||||
[promoteAgents],
|
||||
);
|
||||
const promoteExplicitlyAddressedAgents = React.useCallback(
|
||||
(promotion: { expectedRevision?: number; pubkeys: readonly string[] }) =>
|
||||
promoteAgents({ ...promotion, requirePreference: false }),
|
||||
[promoteAgents],
|
||||
(promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => {
|
||||
promoteAgents({
|
||||
...promotion,
|
||||
reinstateExcluded: true,
|
||||
requirePreference: false,
|
||||
});
|
||||
requestPreferenceChange(true);
|
||||
},
|
||||
[promoteAgents, requestPreferenceChange],
|
||||
);
|
||||
|
||||
const dismissConfirmation = React.useCallback(
|
||||
() => setConfirmation(null),
|
||||
[],
|
||||
);
|
||||
const dismissConfirmation = clearConfirmation;
|
||||
const turnOffConfirmation = React.useCallback(() => {
|
||||
if (!confirmation) return;
|
||||
setConfirmation(null);
|
||||
removePersistentAgentAudienceMembersIfUnchanged({
|
||||
expectedRevision: confirmation.expectedRevision,
|
||||
pubkeys: confirmation.pubkeys,
|
||||
scope: confirmation.scope,
|
||||
});
|
||||
onTurnOff();
|
||||
}, [confirmation, onTurnOff]);
|
||||
clearConfirmation();
|
||||
requestPreferenceChange(false, confirmation);
|
||||
}, [clearConfirmation, confirmation, requestPreferenceChange]);
|
||||
|
||||
return {
|
||||
confirmationTitle:
|
||||
confirmation?.scope === audienceScope ? confirmation.title : null,
|
||||
confirmationTitle: confirmationIsCurrent ? confirmation.title : null,
|
||||
completeOptionsReveal,
|
||||
dismissConfirmation,
|
||||
openOptionsRequest,
|
||||
promoteExplicitlyAddressedAgents,
|
||||
promoteMentionedAgents,
|
||||
setConfirmationHovered,
|
||||
turnOffConfirmation,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, afterEach, before, test } from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
before(() => {
|
||||
Object.assign(globalThis, {
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
window: dom.window,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { cleanup } = await import("@testing-library/react");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
after(() => dom.window.close());
|
||||
|
||||
test("turning off automatic mentions opens a closed mention picker first", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useComposerMentionPicker } = await import(
|
||||
"./useComposerMentionPicker.ts"
|
||||
);
|
||||
const openCalls = [];
|
||||
let turnOffCount = 0;
|
||||
const { result } = renderHook(() =>
|
||||
useComposerMentionPicker({
|
||||
mentions: {
|
||||
cancelMentionAutocomplete: () => {},
|
||||
isMentionOpen: false,
|
||||
openMentionPicker: (...args) => openCalls.push(args),
|
||||
updateMentionQuery: () => {},
|
||||
},
|
||||
onTurnOffAutoPinConfirmation: () => {
|
||||
turnOffCount += 1;
|
||||
},
|
||||
richText: {
|
||||
editor: {},
|
||||
focus: () => {},
|
||||
getPlainTextAndCursor: () => ({ cursor: 4, text: "ping" }),
|
||||
},
|
||||
setIsEmojiPickerOpen: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => result.current.turnOff());
|
||||
|
||||
assert.deepEqual(openCalls, [[4, "first-agent"]]);
|
||||
assert.equal(turnOffCount, 1);
|
||||
});
|
||||
|
||||
test("turning off automatic mentions refreshes an open picker without closing it", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useComposerMentionPicker } = await import(
|
||||
"./useComposerMentionPicker.ts"
|
||||
);
|
||||
let pickerMutationCount = 0;
|
||||
let turnOffCount = 0;
|
||||
const { result } = renderHook(() =>
|
||||
useComposerMentionPicker({
|
||||
mentions: {
|
||||
cancelMentionAutocomplete: () => {
|
||||
pickerMutationCount += 1;
|
||||
},
|
||||
isMentionOpen: true,
|
||||
openMentionPicker: () => {
|
||||
pickerMutationCount += 1;
|
||||
},
|
||||
updateMentionQuery: () => {},
|
||||
},
|
||||
onTurnOffAutoPinConfirmation: () => {
|
||||
turnOffCount += 1;
|
||||
},
|
||||
richText: {
|
||||
editor: {},
|
||||
focus: () => {},
|
||||
getPlainTextAndCursor: () => ({ cursor: 4, text: "ping" }),
|
||||
},
|
||||
setIsEmojiPickerOpen: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => result.current.turnOff());
|
||||
|
||||
assert.equal(pickerMutationCount, 1);
|
||||
assert.equal(turnOffCount, 1);
|
||||
});
|
||||
@@ -5,21 +5,23 @@ import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTex
|
||||
|
||||
export function useComposerMentionPicker({
|
||||
mentions,
|
||||
onTurnOffAutoPinConfirmation,
|
||||
richText,
|
||||
setIsEmojiPickerOpen,
|
||||
}: {
|
||||
mentions: UseMentionsResult;
|
||||
onTurnOffAutoPinConfirmation: () => void;
|
||||
richText: UseRichTextEditorResult;
|
||||
setIsEmojiPickerOpen: (open: boolean) => void;
|
||||
}) {
|
||||
const {
|
||||
cancelMentionAutocomplete,
|
||||
isMentionOpen,
|
||||
openMentionPicker,
|
||||
openMentionPicker: setMentionPickerOpen,
|
||||
updateMentionQuery,
|
||||
} = mentions;
|
||||
const { editor, focus, getPlainTextAndCursor } = richText;
|
||||
return React.useCallback(
|
||||
const openMentionPicker = React.useCallback(
|
||||
(insertTrigger = true) => {
|
||||
if (!editor) return;
|
||||
const { text, cursor } = getPlainTextAndCursor();
|
||||
@@ -30,7 +32,7 @@ export function useComposerMentionPicker({
|
||||
focus();
|
||||
return;
|
||||
}
|
||||
openMentionPicker(cursor, "first-agent");
|
||||
setMentionPickerOpen(cursor, "first-agent");
|
||||
setIsEmojiPickerOpen(false);
|
||||
focus();
|
||||
return;
|
||||
@@ -55,9 +57,36 @@ export function useComposerMentionPicker({
|
||||
focus,
|
||||
getPlainTextAndCursor,
|
||||
isMentionOpen,
|
||||
openMentionPicker,
|
||||
setMentionPickerOpen,
|
||||
setIsEmojiPickerOpen,
|
||||
updateMentionQuery,
|
||||
],
|
||||
);
|
||||
const openMentionSettings = React.useCallback(
|
||||
() => openMentionPicker(false),
|
||||
[openMentionPicker],
|
||||
);
|
||||
const revealMentionSettings = React.useCallback(() => {
|
||||
if (!editor) return;
|
||||
const { cursor } = getPlainTextAndCursor();
|
||||
setMentionPickerOpen(cursor, "first-agent");
|
||||
setIsEmojiPickerOpen(false);
|
||||
focus();
|
||||
}, [
|
||||
editor,
|
||||
focus,
|
||||
getPlainTextAndCursor,
|
||||
setIsEmojiPickerOpen,
|
||||
setMentionPickerOpen,
|
||||
]);
|
||||
const turnOffAutoPinFromConfirmation = React.useCallback(() => {
|
||||
revealMentionSettings();
|
||||
onTurnOffAutoPinConfirmation();
|
||||
}, [onTurnOffAutoPinConfirmation, revealMentionSettings]);
|
||||
|
||||
return {
|
||||
openMentionPicker,
|
||||
openMentionSettings,
|
||||
turnOff: turnOffAutoPinFromConfirmation,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { stripImplicitAgentMentionPrefix } from "@/features/messages/lib/stripImplicitAgentMentions";
|
||||
|
||||
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore";
|
||||
import {
|
||||
@@ -59,6 +61,8 @@ type UseDraftPersistLifecycleParams = {
|
||||
* closure to capture the latest text before the effect fires.
|
||||
*/
|
||||
syncComposerContentFromEditor: () => string;
|
||||
/** Exact editor prefix inserted by automatic addressing, including separator. */
|
||||
getImplicitAgentMentionPrefix?: () => string;
|
||||
};
|
||||
|
||||
type UseDraftPersistLifecycleResult = {
|
||||
@@ -120,7 +124,17 @@ export function useDraftPersistLifecycle({
|
||||
setSpoileredAttachmentUrls,
|
||||
spoileredAttachmentUrlsRef,
|
||||
syncComposerContentFromEditor,
|
||||
getImplicitAgentMentionPrefix,
|
||||
}: UseDraftPersistLifecycleParams): UseDraftPersistLifecycleResult {
|
||||
const persistedContent = React.useCallback(
|
||||
(content: string) =>
|
||||
stripImplicitAgentMentionPrefix(
|
||||
content,
|
||||
getImplicitAgentMentionPrefix?.() ?? "",
|
||||
),
|
||||
[getImplicitAgentMentionPrefix],
|
||||
);
|
||||
|
||||
const pendingImetaForPersistRef = React.useRef<ImetaMedia[]>([]);
|
||||
const emptyContentIsAuthoritativeRef = React.useRef(false);
|
||||
const isRestoringContentRef = React.useRef(false);
|
||||
@@ -192,7 +206,7 @@ export function useDraftPersistLifecycle({
|
||||
}
|
||||
const content = emptyContentIsAuthoritativeRef.current
|
||||
? ""
|
||||
: syncComposerContentFromEditor();
|
||||
: persistedContent(syncComposerContentFromEditor());
|
||||
persistDraft(
|
||||
effectiveDraftKey,
|
||||
content,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
|
||||
|
||||
type GeneratedMention = { pubkey: string; prefix: string };
|
||||
|
||||
/** Tracks the leading mentions that automatic addressing inserted per draft. */
|
||||
export function useImplicitAgentMentionProvenance(
|
||||
effectiveDraftKey: string | null | undefined,
|
||||
) {
|
||||
const byDraftRef = React.useRef(new Map<string, GeneratedMention[]>());
|
||||
|
||||
const getPrefix = React.useCallback(() => {
|
||||
if (!effectiveDraftKey) return "";
|
||||
return (
|
||||
byDraftRef.current
|
||||
.get(effectiveDraftKey)
|
||||
?.map((fragment) => fragment.prefix)
|
||||
.join("") ?? ""
|
||||
);
|
||||
}, [effectiveDraftKey]);
|
||||
|
||||
const add = React.useCallback(
|
||||
(insertedFragments: readonly GeneratedMention[]) => {
|
||||
if (!effectiveDraftKey) return;
|
||||
const fragments = byDraftRef.current.get(effectiveDraftKey) ?? [];
|
||||
const knownPubkeys = new Set(
|
||||
fragments.map((fragment) => fragment.pubkey),
|
||||
);
|
||||
byDraftRef.current.set(effectiveDraftKey, [
|
||||
...insertedFragments.filter(
|
||||
(fragment) => !knownPubkeys.has(fragment.pubkey),
|
||||
),
|
||||
...fragments,
|
||||
]);
|
||||
trimMapToSize(byDraftRef.current, 200);
|
||||
},
|
||||
[effectiveDraftKey],
|
||||
);
|
||||
|
||||
const remove = React.useCallback(
|
||||
(pubkey: string) => {
|
||||
if (!effectiveDraftKey) return;
|
||||
const fragments = byDraftRef.current.get(effectiveDraftKey) ?? [];
|
||||
byDraftRef.current.set(
|
||||
effectiveDraftKey,
|
||||
fragments.filter((fragment) => fragment.pubkey !== pubkey),
|
||||
);
|
||||
},
|
||||
[effectiveDraftKey],
|
||||
);
|
||||
|
||||
return { add, getPrefix, remove };
|
||||
}
|
||||
@@ -166,7 +166,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
|
||||
{
|
||||
id: "always-address-agent",
|
||||
label: "Always address agent",
|
||||
description: "Address the default agent, or select the highlighted agent",
|
||||
description: "Address the default agent, or toggle the highlighted agent",
|
||||
keys: "⇧⌘M",
|
||||
keysWindows: "Ctrl+Shift+M",
|
||||
category: "Messages",
|
||||
|
||||
@@ -1411,7 +1411,16 @@ declare global {
|
||||
}) => unknown;
|
||||
__BUZZ_E2E_SEED_MOCK_REMINDERS__?: (reminders: RelayEvent[]) => void;
|
||||
__BUZZ_E2E_QUERY_CLIENT__?: {
|
||||
invalidateQueries: (filters: { queryKey: readonly unknown[] }) => unknown;
|
||||
invalidateQueries: (filters: {
|
||||
queryKey: readonly unknown[];
|
||||
exact?: boolean;
|
||||
}) => unknown;
|
||||
getQueryState: (queryKey: readonly unknown[]) =>
|
||||
| {
|
||||
fetchStatus: "fetching" | "paused" | "idle";
|
||||
status: "pending" | "error" | "success";
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
__BUZZ_E2E_MD_PARSE_COUNT__?: () => number;
|
||||
/**
|
||||
|
||||
@@ -425,10 +425,10 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as
|
||||
await expect(
|
||||
page.getByTestId(`composer-address-lock-${relayPubkey}`),
|
||||
).toHaveCount(0);
|
||||
await input.fill("local");
|
||||
await input.pressSequentially("local");
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "local"))
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@carl local"))
|
||||
.toEqual([managedPubkey]);
|
||||
await expect(input).toHaveText("@carl ");
|
||||
|
||||
@@ -448,7 +448,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as
|
||||
await expect(
|
||||
page.getByTestId(`composer-address-lock-${managedPubkey}`),
|
||||
).toHaveCount(0);
|
||||
await input.fill("remote");
|
||||
await input.pressSequentially("remote");
|
||||
await page.getByTestId("send-message").click();
|
||||
const sendWithoutInviting = page.getByRole("button", { name: "Do nothing" });
|
||||
try {
|
||||
@@ -458,7 +458,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as
|
||||
// In-channel selections send immediately without opening the prompt.
|
||||
}
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "remote"))
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@carl remote"))
|
||||
.toEqual([relayPubkey]);
|
||||
|
||||
await page.getByTestId("channel-members-trigger").click();
|
||||
@@ -1673,30 +1673,60 @@ test("forum sends revalidate relay-agent authorization before signing", async ({
|
||||
.not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
||||
});
|
||||
|
||||
test("relay-only allowlisted agents are visible in channel mentions", async ({
|
||||
test("managed agents use the channel roster for membership labels", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
relayAgents: [
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
||||
name: "quinn",
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
|
||||
channelNames: ["general"],
|
||||
pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY,
|
||||
name: "carl",
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(channelId) =>
|
||||
window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState([
|
||||
"channels",
|
||||
channelId,
|
||||
"members",
|
||||
])?.status,
|
||||
GENERAL_CHANNEL_ID,
|
||||
),
|
||||
)
|
||||
.toBe("success");
|
||||
await page.evaluate(
|
||||
async ({ channelId, pubkey }) => {
|
||||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||
if (!invoke) throw new Error("Mock bridge is not installed.");
|
||||
await invoke("add_channel_members", {
|
||||
channelId,
|
||||
pubkeys: [pubkey],
|
||||
role: "bot",
|
||||
});
|
||||
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
||||
queryKey: ["channels"],
|
||||
exact: true,
|
||||
});
|
||||
},
|
||||
{
|
||||
channelId: GENERAL_CHANNEL_ID,
|
||||
pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY,
|
||||
},
|
||||
);
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill("@quinn");
|
||||
await input.fill("@carl");
|
||||
|
||||
const dropdown = autocomplete(page);
|
||||
await expect(dropdown.getByText("quinn")).toBeVisible();
|
||||
await expect(dropdown.getByText("agent")).toBeVisible();
|
||||
const carlRow = autocomplete(page).locator("button", { hasText: "carl" });
|
||||
await expect(carlRow).toBeVisible();
|
||||
await expect(carlRow.getByText("agent")).toBeVisible();
|
||||
await expect(carlRow.getByText("not in channel")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("relay-agent directory errors fail closed and recover after a fresh fetch", async ({
|
||||
@@ -1734,7 +1764,27 @@ test("relay-agent directory errors fail closed and recover after a fresh fetch",
|
||||
queryKey: ["relay-agents"],
|
||||
});
|
||||
});
|
||||
await expect(autocomplete(page).getByText("quinn")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["relay-agents"])
|
||||
?.fetchStatus,
|
||||
),
|
||||
)
|
||||
.toBe("fetching");
|
||||
await expect(autocomplete(page).getByText("quinn")).toBeVisible({
|
||||
timeout: 200,
|
||||
});
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["relay-agents"])
|
||||
?.fetchStatus,
|
||||
),
|
||||
)
|
||||
.toBe("idle");
|
||||
await expect(autocomplete(page).getByText("quinn")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1917,6 +1967,56 @@ test("targeted revocation before send causes no agent side effects", async ({
|
||||
}
|
||||
});
|
||||
|
||||
test("selected relay agents are invited as bots before sending", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
relayAgents: [
|
||||
{
|
||||
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
||||
name: "quinn",
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
|
||||
channelNames: ["general"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill("@quinn");
|
||||
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
||||
await expect(quinnRow).toBeVisible();
|
||||
await expect(quinnRow.getByText("not in channel")).toHaveCount(0);
|
||||
await quinnRow.click();
|
||||
await page.keyboard.type("hello");
|
||||
|
||||
const baselinePayloadCount = (await readCommandPayloadLog(page)).length;
|
||||
await page.getByTestId("send-message").click();
|
||||
const inviteButton = page.getByRole("button", {
|
||||
name: "Invite",
|
||||
exact: true,
|
||||
});
|
||||
await expect(inviteButton).toBeVisible();
|
||||
await inviteButton.click();
|
||||
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
||||
.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
||||
const sendCommands = (await readCommandPayloadLog(page)).slice(
|
||||
baselinePayloadCount,
|
||||
);
|
||||
const addCommand = sendCommands.find(
|
||||
(entry) => entry.command === "add_channel_members",
|
||||
);
|
||||
expect(addCommand?.payload).toMatchObject({
|
||||
channelId: GENERAL_CHANNEL_ID,
|
||||
pubkeys: [ALLOWLIST_RELAY_AGENT_PUBKEY],
|
||||
role: "bot",
|
||||
});
|
||||
});
|
||||
|
||||
test("selected relay agents revoked after the invite prompt cause no side effects", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/persistent-agent-audience";
|
||||
const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
|
||||
const AGENT_A = "a".repeat(64);
|
||||
const AGENT_B = "b".repeat(64);
|
||||
const THREAD_ROOT_ID = "mock-general-welcome";
|
||||
@@ -84,11 +85,11 @@ async function readComposerCaret(input: Locator) {
|
||||
});
|
||||
}
|
||||
|
||||
async function pressPrimaryShift(page: Page, key: "M") {
|
||||
async function pressPrimaryShiftM(page: Page) {
|
||||
const isMac = await page.evaluate(() =>
|
||||
/mac|iphone|ipad|ipod/i.test(navigator.platform),
|
||||
);
|
||||
await page.keyboard.press(`${isMac ? "Meta" : "Control"}+Shift+${key}`);
|
||||
await page.keyboard.press(`${isMac ? "Meta" : "Control"}+Shift+M`);
|
||||
}
|
||||
|
||||
async function readOutgoingMentionPubkeys(page: Page, content: string) {
|
||||
@@ -156,6 +157,7 @@ async function emitMockMessage(
|
||||
async function installAudienceFixtures(
|
||||
page: Page,
|
||||
options: {
|
||||
agentAName?: string;
|
||||
deferredComposerUploads?: boolean;
|
||||
sendMessageDelayMs?: number;
|
||||
sendMessageErrors?: string[];
|
||||
@@ -171,12 +173,13 @@ async function installAudienceFixtures(
|
||||
usersBatchDelayMs?: number;
|
||||
} = {},
|
||||
) {
|
||||
const { agentAName = "Morgarita", ...bridgeOptions } = options;
|
||||
await installMockBridge(page, {
|
||||
...options,
|
||||
...bridgeOptions,
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: AGENT_A,
|
||||
name: "Morgarita",
|
||||
name: agentAName,
|
||||
status: "running",
|
||||
channelNames: ["general"],
|
||||
},
|
||||
@@ -285,6 +288,104 @@ test("automatically mentions multiple agents from the mention picker", async ({
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the composer and global automatic mention settings synchronized", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
|
||||
const composer = channelComposer(page);
|
||||
await composer.getByTestId("message-insert-mention").click();
|
||||
const optionsTrigger = composer.getByTestId("mention-options-trigger");
|
||||
await expect(optionsTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
await composer
|
||||
.getByTestId("mention-autocomplete")
|
||||
.getByRole("button", { name: "Automatically mention Morgarita" })
|
||||
.click();
|
||||
await expect(optionsTrigger).toHaveAttribute("aria-expanded", "true");
|
||||
const composerToggle = composer.getByTestId(
|
||||
"mention-keep-agents-pinned-toggle",
|
||||
);
|
||||
await expect(composerToggle).toHaveAttribute("data-state", "unchecked");
|
||||
await expect(composerToggle).toHaveAttribute("data-state", "checked", {
|
||||
timeout: 1_500,
|
||||
});
|
||||
|
||||
await page
|
||||
.getByTestId("composer-auto-pin-confirmation")
|
||||
.getByRole("button", { name: "Turn off" })
|
||||
.click();
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await expect(optionsTrigger).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(composerToggle).toHaveAttribute("data-state", "checked");
|
||||
await expect(composerToggle).toHaveAttribute("data-state", "unchecked", {
|
||||
timeout: 1_500,
|
||||
});
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await page.getByTestId("profile-popover-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
await page.getByTestId("settings-nav-agents").click();
|
||||
const settingsToggle = page
|
||||
.getByTestId("settings-automatic-agent-mentions")
|
||||
.getByRole("switch", { name: "Automatically mention agents" });
|
||||
await expect(settingsToggle).toHaveAttribute("data-state", "unchecked");
|
||||
|
||||
await page.getByTestId("settings-back-to-app").click();
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
await composer.getByTestId("message-insert-mention").click();
|
||||
await composer.getByTestId("mention-options-trigger").click();
|
||||
await expect(
|
||||
composer.getByTestId("mention-keep-agents-pinned-toggle"),
|
||||
).toHaveAttribute("data-state", "unchecked");
|
||||
await expect(
|
||||
composer.getByRole("button", {
|
||||
name: "Automatically mention Morgarita",
|
||||
}),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
test("hides automatic mention state while disabled without clearing the draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await automaticallyMention(composer, "Morgarita");
|
||||
await input.type("draft text");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
|
||||
await page.getByTestId("channel-management-archive").click();
|
||||
await expect(page.getByTestId("channel-management-unarchive")).toBeVisible();
|
||||
await page.getByTestId("auxiliary-panel-close").click();
|
||||
|
||||
await expect(input).toHaveAttribute("contenteditable", "false");
|
||||
await expect(input).toHaveText("@Morgarita draft text");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
|
||||
await page.getByTestId("channel-management-unarchive").click();
|
||||
await expect(page.getByTestId("channel-management-archive")).toBeVisible();
|
||||
await page.getByTestId("auxiliary-panel-close").click();
|
||||
|
||||
await expect(input).toHaveAttribute("contenteditable", "true");
|
||||
await expect(input).toHaveText("@Morgarita draft text");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Tab inserts a one-time agent mention by default", async ({ page }) => {
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
@@ -346,7 +447,7 @@ test("disabling automatic mentions leaves the composer empty after send", async
|
||||
.toContain(AGENT_A);
|
||||
});
|
||||
|
||||
test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({
|
||||
test("primary+Shift+M addresses the default agent, then toggles the highlighted agent in place", async ({
|
||||
page,
|
||||
}) => {
|
||||
await keepMentionedAgentsPinned(page);
|
||||
@@ -356,21 +457,21 @@ test("primary+Shift+M addresses the default agent, then selects the highlighted
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await input.fill("draft text");
|
||||
await pressPrimaryShift(page, "M");
|
||||
await pressPrimaryShiftM(page);
|
||||
|
||||
await expect(input).toHaveText("@alice draft text");
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveText("alice");
|
||||
await expect(
|
||||
page.getByTestId("composer-auto-pin-confirmation"),
|
||||
).toContainText("alice will be mentioned automatically");
|
||||
await pressPrimaryShift(page, "M");
|
||||
await pressPrimaryShiftM(page);
|
||||
await expect(input).toHaveText("draft text");
|
||||
await pressPrimaryShift(page, "M");
|
||||
await pressPrimaryShiftM(page);
|
||||
await expect(input).toHaveText("@alice draft text");
|
||||
await expect(
|
||||
composer
|
||||
.getByTestId("composer-address-locks")
|
||||
.getByRole("button", { name: /^Stop automatically mentioning / }),
|
||||
.getByRole("button", { name: /^Don't automatically mention / }),
|
||||
).toHaveCount(1);
|
||||
|
||||
await input.fill("@Vog");
|
||||
@@ -378,9 +479,9 @@ test("primary+Shift+M addresses the default agent, then selects the highlighted
|
||||
await expect(menu.getByTestId(`mention-suggestion-${AGENT_B}`)).toHaveClass(
|
||||
/(?:^|\s)bg-accent(?:\s|$)/,
|
||||
);
|
||||
await pressPrimaryShift(page, "M");
|
||||
await pressPrimaryShiftM(page);
|
||||
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(input).toHaveText("@Vogue ");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_B}`),
|
||||
@@ -391,7 +492,7 @@ test("primary+Shift+M addresses the default agent, then selects the highlighted
|
||||
await expect(
|
||||
composer
|
||||
.getByTestId("composer-address-locks")
|
||||
.getByRole("button", { name: /^Stop automatically mentioning / }),
|
||||
.getByRole("button", { name: /^Don't automatically mention / }),
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -407,13 +508,13 @@ test("primary+Shift+M favors the most recently mentioned eligible agent", async
|
||||
await input.press("ArrowLeft");
|
||||
await input.press("ArrowLeft");
|
||||
await expect.poll(() => readComposerCaret(input)).toBe(8);
|
||||
await pressPrimaryShift(page, "M");
|
||||
await pressPrimaryShiftM(page);
|
||||
|
||||
await expect(input).toHaveText("@Vogue draft text");
|
||||
await expect.poll(() => readComposerCaret(input)).toBe(15);
|
||||
await pressPrimaryShift(page, "M");
|
||||
await pressPrimaryShiftM(page);
|
||||
await expect(input).toHaveText("draft text");
|
||||
await pressPrimaryShift(page, "M");
|
||||
await pressPrimaryShiftM(page);
|
||||
await expect(input).toHaveText("@Vogue draft text");
|
||||
});
|
||||
|
||||
@@ -468,12 +569,14 @@ test("the mention button opens settings and can undo an address", async ({
|
||||
await expect(page.getByTestId("user-profile-panel")).toHaveCount(0);
|
||||
await expect(
|
||||
menu.getByRole("button", {
|
||||
name: "Stop automatically mentioning Morgarita",
|
||||
name: "Don't automatically mention Morgarita in this conversation",
|
||||
}),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await menu
|
||||
.getByRole("button", { name: "Stop automatically mentioning Morgarita" })
|
||||
.getByRole("button", {
|
||||
name: "Don't automatically mention Morgarita in this conversation",
|
||||
})
|
||||
.click();
|
||||
await expect(input).toHaveText("draft text");
|
||||
await expect(
|
||||
@@ -724,44 +827,513 @@ test("the auto-pin popover can turn off automatic agent mentions", async ({
|
||||
);
|
||||
await autoPinConfirmation.getByRole("button", { name: "Turn off" }).click();
|
||||
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await expect(composer.getByTestId("mention-options-trigger")).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
await expect(
|
||||
composer.getByTestId("mention-keep-agents-pinned-toggle"),
|
||||
).toHaveAttribute("data-state", "unchecked");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
await expect(autoPinConfirmation).toHaveCount(0);
|
||||
|
||||
await composer.getByTestId("message-insert-mention").click();
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await composer.getByTestId("mention-options-trigger").click();
|
||||
await expect(
|
||||
composer.getByTestId("mention-keep-agents-pinned-toggle"),
|
||||
).toHaveAttribute("data-state", "unchecked");
|
||||
|
||||
await input.press("Escape");
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("channel automatic mentions carry into threads and stay synchronized", async ({
|
||||
test("the auto-pin popover remains open while hovered", async ({ page }) => {
|
||||
await keepMentionedAgentsPinned(page);
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await input.fill("@Mor");
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await input.press("Tab");
|
||||
|
||||
const autoPinConfirmation = page.getByTestId(
|
||||
"composer-auto-pin-confirmation",
|
||||
);
|
||||
await autoPinConfirmation.hover();
|
||||
await page.waitForTimeout(4_250);
|
||||
await expect(autoPinConfirmation).toBeVisible();
|
||||
|
||||
await input.press("Escape");
|
||||
await expect(autoPinConfirmation).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("removing the mention chip dismisses the auto-pin popover", async ({
|
||||
page,
|
||||
}) => {
|
||||
await keepMentionedAgentsPinned(page);
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await input.fill("@Mor");
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await input.press("Tab");
|
||||
|
||||
const autoPinConfirmation = page.getByTestId(
|
||||
"composer-auto-pin-confirmation",
|
||||
);
|
||||
await expect(autoPinConfirmation).toBeVisible();
|
||||
|
||||
const selectAllShortcut = await page.evaluate(() =>
|
||||
/mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A",
|
||||
);
|
||||
await input.press(selectAllShortcut);
|
||||
await input.press("Backspace");
|
||||
|
||||
await expect(input).toHaveText("");
|
||||
await expect(autoPinConfirmation).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("automatic mentions are scoped to their channel or thread composer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
await automaticallyMention(channelComposer(page), "Morgarita");
|
||||
await expect(
|
||||
channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
|
||||
await openThread(page);
|
||||
const channelAutomaticMention = channelComposer(page).getByTestId(
|
||||
`composer-address-lock-${AGENT_A}`,
|
||||
);
|
||||
const threadAutomaticMention = threadComposer(page).getByTestId(
|
||||
`composer-address-lock-${AGENT_A}`,
|
||||
);
|
||||
await expect(channelAutomaticMention).toBeVisible();
|
||||
await expect(threadAutomaticMention).toBeVisible();
|
||||
await expect(
|
||||
threadComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
|
||||
await threadComposer(page)
|
||||
await automaticallyMention(threadComposer(page), "Vogue");
|
||||
await openGeneral(page);
|
||||
await expect(
|
||||
channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
channelComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`),
|
||||
).toHaveCount(0);
|
||||
|
||||
await openThread(page);
|
||||
await expect(
|
||||
threadComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`),
|
||||
).toBeVisible();
|
||||
|
||||
await openThread(page, "mock-general-alice");
|
||||
await expect(
|
||||
threadComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a thread automatic mention preserves an explicitly unpinned root agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await keepMentionedAgentsPinned(page);
|
||||
await installAudienceFixtures(page, { agentAName: "claude code" });
|
||||
await openGeneral(page);
|
||||
|
||||
const rootComposer = channelComposer(page);
|
||||
const rootInput = rootComposer.getByTestId("message-input");
|
||||
await automaticallyMention(rootComposer, "claude code");
|
||||
await expect(
|
||||
rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
await rootComposer
|
||||
.getByTestId(`composer-address-lock-remove-${AGENT_A}`)
|
||||
.click();
|
||||
await expect(threadAutomaticMention).toHaveCount(0);
|
||||
await expect(channelAutomaticMention).toHaveCount(0);
|
||||
await expect(rootInput).toHaveText("");
|
||||
await expect(
|
||||
rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
|
||||
await rootInput.fill("@cla");
|
||||
await expect(rootComposer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await rootInput.press("Tab");
|
||||
await rootInput.type("one time");
|
||||
await rootInput.press("Enter");
|
||||
await expect(rootInput).toHaveText("");
|
||||
await expect(
|
||||
rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
|
||||
await openThread(page);
|
||||
const activeThreadComposer = threadComposer(page);
|
||||
const threadInput = activeThreadComposer.getByTestId("message-input");
|
||||
await threadInput.fill("@cla");
|
||||
await expect(
|
||||
activeThreadComposer.getByTestId("mention-autocomplete"),
|
||||
).toBeVisible();
|
||||
await threadInput.press("Tab");
|
||||
await expect(
|
||||
activeThreadComposer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
await threadInput.type("thread message");
|
||||
await threadInput.press("Enter");
|
||||
|
||||
await openGeneral(page);
|
||||
await expect(
|
||||
channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
|
||||
const restoredRootInput = channelComposer(page).getByTestId("message-input");
|
||||
await restoredRootInput.fill("@cla");
|
||||
await expect(
|
||||
channelComposer(page).getByTestId("mention-autocomplete"),
|
||||
).toBeVisible();
|
||||
await restoredRootInput.press("Tab");
|
||||
await expect(
|
||||
channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
await restoredRootInput.type("one time");
|
||||
await restoredRootInput.press("Enter");
|
||||
|
||||
await expect(restoredRootInput).toHaveText("");
|
||||
await expect(
|
||||
channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("an unchecked agent remains excluded while automatic mentions stay enabled", async ({
|
||||
page,
|
||||
}) => {
|
||||
await keepMentionedAgentsPinned(page);
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
await automaticallyMention(channelComposer(page), "Morgarita");
|
||||
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click();
|
||||
await expect(input).toHaveText("");
|
||||
await input.fill("@Mor");
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await input.press("Tab");
|
||||
await input.type("one time");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toHaveText("");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("re-adding a deleted automatic mention restores its automatic mention state immediately", async ({
|
||||
page,
|
||||
}) => {
|
||||
await keepMentionedAgentsPinned(page);
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await automaticallyMention(composer, "Morgarita");
|
||||
|
||||
const selectAllShortcut = await page.evaluate(() =>
|
||||
/mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A",
|
||||
);
|
||||
await input.press(selectAllShortcut);
|
||||
await input.press("Backspace");
|
||||
|
||||
await expect(input).toHaveText("");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
|
||||
await input.fill("@Mor");
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
|
||||
await input.type("re-added");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("implicit automatic mentions stay out of persisted drafts", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
await automaticallyMention(channelComposer(page), "Morgarita");
|
||||
const input = channelComposer(page).getByTestId("message-input");
|
||||
await input.type("draft text");
|
||||
|
||||
await openThread(page);
|
||||
await openGeneral(page);
|
||||
|
||||
await expect(input).toHaveText("@Morgarita draft text");
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
|
||||
await input.pressSequentially(" continues");
|
||||
await expect(input).toHaveText("@Morgarita draft text continues");
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
|
||||
|
||||
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((channelId) => {
|
||||
for (const storageKey of Object.keys(window.localStorage)) {
|
||||
if (!storageKey.startsWith("buzz-drafts.v2:")) continue;
|
||||
const drafts = JSON.parse(
|
||||
window.localStorage.getItem(storageKey) ?? "{}",
|
||||
) as Record<string, { channelId?: string; content?: string }>;
|
||||
const draft = drafts[channelId];
|
||||
if (draft?.channelId === channelId) return draft.content ?? "";
|
||||
}
|
||||
return "";
|
||||
}, CHANNEL_ID),
|
||||
)
|
||||
.toBe("draft text continues");
|
||||
});
|
||||
|
||||
test("an authored duplicate leading mention survives draft restoration", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
await automaticallyMention(channelComposer(page), "Morgarita");
|
||||
const input = channelComposer(page).getByTestId("message-input");
|
||||
await input.pressSequentially("@Morgarita authored duplicate");
|
||||
|
||||
await openThread(page);
|
||||
await openGeneral(page);
|
||||
|
||||
await expect(input).toHaveText("@Morgarita @Morgarita authored duplicate");
|
||||
// Exact typed mentions now resolve on Space, so both the automatic prefix and
|
||||
// the authored duplicate retain mention identity after restoration.
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveCount(2);
|
||||
|
||||
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((channelId) => {
|
||||
for (const storageKey of Object.keys(window.localStorage)) {
|
||||
if (!storageKey.startsWith("buzz-drafts.v2:")) continue;
|
||||
const drafts = JSON.parse(
|
||||
window.localStorage.getItem(storageKey) ?? "{}",
|
||||
) as Record<string, { channelId?: string; content?: string }>;
|
||||
const draft = drafts[channelId];
|
||||
if (draft?.channelId === channelId) return draft.content ?? "";
|
||||
}
|
||||
return "";
|
||||
}, CHANNEL_ID),
|
||||
)
|
||||
.toBe("@Morgarita authored duplicate");
|
||||
});
|
||||
|
||||
test("typed deletion preserves an identical authored mention in drafts", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await automaticallyMention(composer, "Morgarita");
|
||||
|
||||
const selectAllShortcut = await page.evaluate(() =>
|
||||
/mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A",
|
||||
);
|
||||
await input.press(selectAllShortcut);
|
||||
await input.press("Backspace");
|
||||
await expect(input).toHaveText("");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
|
||||
await input.pressSequentially("@Morgarita manual after typed deletion");
|
||||
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((channelId) => {
|
||||
for (const storageKey of Object.keys(window.localStorage)) {
|
||||
if (!storageKey.startsWith("buzz-drafts.v2:")) continue;
|
||||
const draft = (
|
||||
JSON.parse(
|
||||
window.localStorage.getItem(storageKey) ?? "{}",
|
||||
) as Record<string, { content?: string }>
|
||||
)[channelId];
|
||||
if (draft) return draft.content ?? "";
|
||||
}
|
||||
return "";
|
||||
}, CHANNEL_ID),
|
||||
)
|
||||
.toBe("@Morgarita manual after typed deletion");
|
||||
});
|
||||
|
||||
test("removing an automatic mention preserves an identical authored mention in drafts", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
|
||||
await automaticallyMention(composer, "Morgarita");
|
||||
await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click();
|
||||
await input.pressSequentially("@Morgarita manual after removal");
|
||||
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((channelId) => {
|
||||
for (const storageKey of Object.keys(window.localStorage)) {
|
||||
if (!storageKey.startsWith("buzz-drafts.v2:")) continue;
|
||||
const draft = (
|
||||
JSON.parse(
|
||||
window.localStorage.getItem(storageKey) ?? "{}",
|
||||
) as Record<string, { content?: string }>
|
||||
)[channelId];
|
||||
if (draft) return draft.content ?? "";
|
||||
}
|
||||
return "";
|
||||
}, CHANNEL_ID),
|
||||
)
|
||||
.toBe("@Morgarita manual after removal");
|
||||
});
|
||||
|
||||
test("multiple automatic mentions stay out of persisted drafts", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await automaticallyMention(composer, "Morgarita");
|
||||
await automaticallyMention(composer, "Vogue");
|
||||
await input.pressSequentially("draft text");
|
||||
|
||||
await openThread(page);
|
||||
await openGeneral(page);
|
||||
await expect(input).toHaveText("@Vogue @Morgarita draft text");
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveCount(2);
|
||||
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((channelId) => {
|
||||
for (const storageKey of Object.keys(window.localStorage)) {
|
||||
if (!storageKey.startsWith("buzz-drafts.v2:")) continue;
|
||||
const draft = (
|
||||
JSON.parse(
|
||||
window.localStorage.getItem(storageKey) ?? "{}",
|
||||
) as Record<string, { content?: string }>
|
||||
)[channelId];
|
||||
if (draft) return draft.content ?? "";
|
||||
}
|
||||
return "";
|
||||
}, CHANNEL_ID),
|
||||
)
|
||||
.toBe("draft text");
|
||||
});
|
||||
|
||||
test("re-enabling an automatic mention preserves an authored duplicate after draft restoration", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
|
||||
await automaticallyMention(composer, "Morgarita");
|
||||
await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click();
|
||||
await expect(input).toHaveText("");
|
||||
|
||||
await automaticallyMention(composer, "Morgarita");
|
||||
await input.pressSequentially("@Morgarita authored duplicate");
|
||||
await openThread(page);
|
||||
await openGeneral(page);
|
||||
|
||||
await expect(input).toHaveText("@Morgarita @Morgarita authored duplicate");
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveCount(2);
|
||||
|
||||
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((channelId) => {
|
||||
for (const storageKey of Object.keys(window.localStorage)) {
|
||||
if (!storageKey.startsWith("buzz-drafts.v2:")) continue;
|
||||
const drafts = JSON.parse(
|
||||
window.localStorage.getItem(storageKey) ?? "{}",
|
||||
) as Record<string, { channelId?: string; content?: string }>;
|
||||
const draft = drafts[channelId];
|
||||
if (draft?.channelId === channelId) return draft.content ?? "";
|
||||
}
|
||||
return "";
|
||||
}, CHANNEL_ID),
|
||||
)
|
||||
.toBe("@Morgarita authored duplicate");
|
||||
});
|
||||
|
||||
test("a restored multi-word automatic mention remains a chip with the caret after its space", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page, { agentAName: "claude code" });
|
||||
await openGeneral(page);
|
||||
const originalComposer = channelComposer(page);
|
||||
await automaticallyMention(originalComposer, "claude code");
|
||||
const originalInput = originalComposer.getByTestId("message-input");
|
||||
await originalInput.pressSequentially("hello");
|
||||
await originalInput.press("Enter");
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@claude code hello"))
|
||||
.toContain(AGENT_A);
|
||||
await expect(originalInput).toHaveText("@claude code ");
|
||||
|
||||
await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
await openGeneral(page);
|
||||
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
const expectedContent = "@claude code ";
|
||||
await expect(input).toHaveText(expectedContent);
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
composer.getByRole("button", { name: "Manage automatic agent mentions" }),
|
||||
).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
|
||||
await expect(input).toBeFocused();
|
||||
await expect
|
||||
.poll(() => readComposerCaret(input))
|
||||
.toBe(expectedContent.length);
|
||||
|
||||
await input.pressSequentially("follow-up");
|
||||
await expect(input).toHaveText("@claude code follow-up");
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("reduced motion removes addressed agents without spatial animation", async ({
|
||||
@@ -785,7 +1357,7 @@ test("reduced motion removes addressed agents without spatial animation", async
|
||||
await expect(removeButton).toHaveCSS("transform", "none");
|
||||
|
||||
await removeButton.click();
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await expect(input).toHaveText("");
|
||||
await expect(removeButton).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -832,7 +1404,7 @@ test("captures the lightweight auto-pin popover", async ({ page }) => {
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await input.fill("draft text");
|
||||
await pressPrimaryShift(page, "M");
|
||||
await pressPrimaryShiftM(page);
|
||||
await expect(input).toHaveText("@alice draft text");
|
||||
|
||||
const addressControl = composer
|
||||
|
||||
Reference in New Issue
Block a user