Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
476 changes: 476 additions & 0 deletions desktop/src/features/channels/mentionAdmissionJourney.test.mjs

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions desktop/src/features/messages/lib/mentionCandidates.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
buildTeamMentionCandidates,
formatTeamMention,
sameTeamMentionRecipients,
} from "./mentionCandidates.ts";

function persona(id, displayName, isActive = true) {
Expand Down Expand Up @@ -171,3 +172,27 @@ test("teams with identity and persona display-name collisions are not suggested"
[],
);
});

test("team recipient equality ignores multiplicity, order and names, not exact identity", () => {
const a = { kind: "identity", displayName: "A", pubkey: "ab".repeat(32) };
const b = { ...a, pubkey: "cd".repeat(32) };
const p = { kind: "persona", displayName: "A", personaId: "a" };
for (const [left, right, equal] of [
[[], [], true],
[[a], [], false],
[[], [a], false],
[[a, a], [a, b], false],
[[a, b], [a, a], false],
[[a, a], [a], true],
[[a], [a, a], true],
[
[a, b],
[b, { ...a, displayName: "Renamed", pubkey: a.pubkey.toUpperCase() }],
true,
],
[[p, p], [p], true],
[[p], [{ ...p, personaId: "b" }], false],
[[p], [{ ...p, pubkey: a.pubkey }], false],
])
assert.equal(sameTeamMentionRecipients(left, right), equal);
});
21 changes: 20 additions & 1 deletion desktop/src/features/messages/lib/mentionCandidates.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { MentionAction } from "./mentionPresentation";
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
import type {
AgentPersona,
AgentTeam,
ChannelRole,
UserSearchResult,
} from "@/shared/api/types";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";

export function formatSearchUserDisplayName(user: UserSearchResult) {
return user.displayName?.trim() || user.nip05Handle?.trim() || null;
Expand Down Expand Up @@ -33,6 +34,7 @@ export type TeamMentionMember = {
};

export type MentionCandidate = {
action?: MentionAction;
kind: "identity" | "persona" | "team";
pubkey?: string;
personaId?: string;
Expand Down Expand Up @@ -157,3 +159,20 @@ export function formatTeamMention(
) {
return `${teamName}(${members.map((member) => `@${member.displayName}`).join(" ")}) `;
}

/** Compare exact team recipient sets; duplicate members and presentation order are irrelevant. */
export function sameTeamMentionRecipients(
selected: readonly TeamMentionMember[],
current: readonly TeamMentionMember[] = [],
): boolean {
const identity = (member: TeamMentionMember) =>
member.pubkey
? `key:${normalizePubkey(member.pubkey)}`
: `persona:${member.personaId}`;
const selectedSet = new Set(selected.map(identity));
const currentSet = new Set(current.map(identity));
return (
selectedSet.size === currentSet.size &&
[...selectedSet].every((key) => currentSet.has(key))
);
}
6 changes: 6 additions & 0 deletions desktop/src/features/messages/lib/mentionPresentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** Presentation only. Publication still performs fresh authorization. */
export type MentionAction = "mention" | "invite" | "checking" | "unavailable";

export function isMentionActionable(candidate: { action?: MentionAction }) {
return candidate.action !== "checking" && candidate.action !== "unavailable";
}
68 changes: 67 additions & 1 deletion desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isMentionActionable } from "./mentionPresentation";
import * as React from "react";
import {
useManagedAgentsQuery,
Expand Down Expand Up @@ -58,6 +59,7 @@ import {
appendUniqueName,
buildTeamMentionCandidates,
formatTeamMention,
sameTeamMentionRecipients,
type MentionCandidate,
} from "./mentionCandidates";
import { buildMentionCandidates } from "./buildMentionCandidates";
Expand Down Expand Up @@ -447,12 +449,75 @@ export function useMentions(
const { mentionSelectedIndex, setMentionSelectedIndex: setSelected } =
mentionSelection;
const isMentionOpen = mentionQuery !== null && suggestions.length > 0;
// Recheck against this render's exact-key evidence even if a child retained
// an older row/callback. A rejected selection must not establish draft intent.
const admissionScope = React.useMemo(
() => ({ currentPubkey, channelId }),
[currentPubkey, channelId],
);
const admissionRef = React.useRef({
scope: admissionScope,
candidates: mentionCandidatesWithTeams,
});
admissionRef.current = {
scope: admissionScope,
candidates: mentionCandidatesWithTeams,
};
const canSelectMention = React.useCallback(
(suggestion: MentionSuggestion) => {
const current = admissionRef.current.candidates.find((candidate) =>
suggestion.pubkey
? candidate.pubkey === normalizePubkey(suggestion.pubkey)
: suggestion.teamId
? candidate.teamId === suggestion.teamId
: !!suggestion.personaId &&
candidate.personaId === suggestion.personaId,
);
return (
admissionRef.current.scope === admissionScope &&
!!current &&
(current.kind !== "team" ||
(suggestion.kind === "team" &&
!!suggestion.teamMembers?.length &&
sameTeamMentionRecipients(
suggestion.teamMembers,
current.teamMembers,
) &&
suggestion.teamMembers.every((member) => {
const matches = (target: {
pubkey?: string;
personaId?: string | null;
}) =>
member.pubkey
? target.pubkey === normalizePubkey(member.pubkey)
: !!member.personaId &&
!target.pubkey &&
target.personaId === member.personaId;
return (
current.teamMembers?.some(matches) &&
admissionRef.current.candidates.some(
(target) => matches(target) && isMentionActionable(target),
)
);
}))) &&
isMentionActionable(current) &&
isMentionActionable(suggestion)
);
},
[admissionScope],
);
const insertMention = React.useCallback(
(suggestion: MentionSuggestion, selectionEnd: number): AutocompleteEdit => {
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (!canSelectMention(suggestion))
return {
replaceFromOffset: selectionEnd,
replaceToOffset: selectionEnd,
insertText: "",
};
const [boundSuggestion] = selectedMentionLabels(
[suggestion],
mentionMapRef.current,
Expand Down Expand Up @@ -525,7 +590,7 @@ export function useMentions(
insertText,
};
},
[knownAgentPubkeys, mentionStartIndex, setSelected],
[canSelectMention, knownAgentPubkeys, mentionStartIndex, setSelected],
);
const registerMentionPubkey = React.useCallback(
(displayName: string, pubkey: string, options?: { isAgent?: boolean }) => {
Expand Down Expand Up @@ -837,6 +902,7 @@ export function useMentions(
],
);
return {
canSelectMention,
cancelMentionAutocomplete,
clearMentions,
getDefaultAgentSuggestion,
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/messages/ui/MentionAutocomplete.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { MentionAction } from "@/features/messages/lib/mentionPresentation";
import * as React from "react";
import { Bot, ChevronRight, Pin, Users } from "lucide-react";
import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
Expand All @@ -20,6 +21,7 @@ import { truncatePubkey } from "@/shared/lib/pubkey";
import { getPlatformKeysById } from "@/shared/lib/keyboard-shortcuts";

export type MentionSuggestion = {
action?: MentionAction;
pubkey?: string;
personaId?: string;
teamId?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds
isInlineMentionSelection: () => false,
isMentionOpen: true,
openMentionPicker: (...args) => openPickerCalls.push(args),
canSelectMention: () => true,
registerMentionPubkey: () => {},
mentionStartIndex: text.lastIndexOf("@"),
};
Expand Down Expand Up @@ -117,6 +118,7 @@ test("always addressing a new agent delegates the first add for immediate confir
getMentionDisplayName: () => "Agent Ada",
isInlineMentionSelection: () => false,
isMentionOpen: false,
canSelectMention: () => true,
registerMentionPubkey: () => {},
},
onAddressAgentMention: (value) => addressedSuggestions.push(value),
Expand Down Expand Up @@ -156,6 +158,7 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock",
},
],
getMentionDisplayName: () => "Agent Ada",
canSelectMention: () => true,
registerMentionPubkey: () => {},
mentionStartIndex: text.lastIndexOf("@"),
};
Expand Down Expand Up @@ -216,6 +219,7 @@ test("selecting an already addressed agent from the explicit picker pulses its b
cancelMentionAutocomplete: () => {},
getDraftMentionRefs: () => [],
getMentionDisplayName: () => "Agent Ada",
canSelectMention: () => true,
registerMentionPubkey: () => {},
isInlineMentionSelection: () => false,
insertMention: () => ({
Expand Down Expand Up @@ -275,6 +279,7 @@ test("selecting an agent from a typed query immediately auto-addresses it", asyn
cancelMentionAutocomplete: () => {},
getDraftMentionRefs: () => [],
getMentionDisplayName: () => "Agent Ada",
canSelectMention: () => true,
registerMentionPubkey: () => {},
isInlineMentionSelection: () => true,
insertMention: () => ({
Expand Down Expand Up @@ -342,6 +347,7 @@ test("selecting a human mention never changes automatic addressing", async () =>
audienceScope: "channel-scope",
mentions: {
getMentionDisplayName: () => "Alice",
isInlineMentionSelection: () => true,
insertMention: () => ({
replaceFromOffset: 0,
replaceToOffset: 3,
Expand Down Expand Up @@ -394,6 +400,7 @@ test("restoring a multi-word automatic mention into an empty composer focuses af
mentions: {
getDraftMentionRefs: () => [],
getMentionDisplayName: () => "claude code",
canSelectMention: () => true,
registerMentionPubkey: (...args) => {
registeredMentions.push(args);
return args[0];
Expand Down Expand Up @@ -443,6 +450,7 @@ test("restoring before authored text preserves its selection", async () => {
mentions: {
getDraftMentionRefs: () => [],
getMentionDisplayName: () => "Morgarita",
canSelectMention: () => true,
registerMentionPubkey: () => {},
},
onPulseAddressLock: () => {},
Expand Down Expand Up @@ -497,6 +505,7 @@ test("restoring an existing automatic mention re-registers its agent chip", asyn
]
: [],
getMentionDisplayName: () => "claude code",
canSelectMention: () => true,
registerMentionPubkey: (...args) => {
registeredMentions.push(args);
return args[0];
Expand Down Expand Up @@ -638,6 +647,7 @@ test("selecting an agent from the explicit picker auto-addresses it", async () =
cancelMentionAutocomplete: () => {},
getDraftMentionRefs: () => [],
getMentionDisplayName: () => "Agent Ada",
canSelectMention: () => true,
registerMentionPubkey: () => {},
isInlineMentionSelection: () => false,
insertMention: () => ({
Expand Down Expand Up @@ -704,6 +714,7 @@ test("repeatedly selecting an explicitly unpinned agent keeps its mentions manua
{ displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true },
],
getMentionDisplayName: () => "Agent Ada",
canSelectMention: () => true,
registerMentionPubkey: () => {},
isInlineMentionSelection: () => true,
insertMention: () => ({
Expand Down Expand Up @@ -818,6 +829,7 @@ test("restoring after an agent rename keeps the existing automatic mention", asy
{ displayName: oldName, pubkey: "agent-pubkey", isAgent: true },
],
getMentionDisplayName: () => displayName,
canSelectMention: () => true,
registerMentionPubkey: (...args) => {
registeredMentions.push(args);
return args[0];
Expand Down Expand Up @@ -899,6 +911,7 @@ test("automatic mention insertion and restoration use the registered collision-s
snapshotDraftMentionRefs(value, bindings, [...bindings.keys()]),
getMentionDisplayName: (pubkey) =>
[...bindings].find(([, key]) => key === pubkey)?.[0] ?? "carl",
canSelectMention: () => true,
registerMentionPubkey: (name, pubkey) => {
const label = selectedMentionLabel(name, pubkey, bindings);
bindings.set(label, pubkey);
Expand Down Expand Up @@ -998,6 +1011,7 @@ test("inverse deletion and toggle preserve B and exclude A from the composed sen
snapshotDraftMentionRefs(value, bindings, [...bindings.keys()]),
getMentionDisplayName: (key) =>
[...bindings].find(([, k]) => k === key)?.[0],
canSelectMention: () => true,
registerMentionPubkey: (name, key) => {
const label = selectedMentionLabel(name, key, bindings);
bindings.set(label, key);
Expand Down Expand Up @@ -1097,3 +1111,39 @@ test("implicit prefix removal uses the present exact label rather than a stale a
act(() => result.current.removeAddressedAgent(key));
assert.equal(text, "hello");
});

test("rejected stale selection never pins, tracks, announces or edits", async () => {
const { act, renderHook } = await import("@testing-library/react");
const { useAgentAddressLockPicker } = await import(
"./useAgentAddressLockPicker.ts"
);
const effects = [];
const { result } = renderHook(() =>
useAgentAddressLockPicker({
applyAutocompleteEdit: () => effects.push("edit"),
audience: { pubkeys: [], addPubkey: () => effects.push("audience") },
audienceScope: "room",
mentions: {
getMentionDisplayName: () => "Scout",
isInlineMentionSelection: () => true,
insertMention: () => ({
replaceFromOffset: 1,
replaceToOffset: 1,
insertText: "",
}),
},
onAutoPinAgentMention: () => effects.push("pin"),
onPulseAddressLock: () => effects.push("pulse"),
richText: { getPlainTextAndCursor: () => ({ text: "@", cursor: 1 }) },
}),
);
act(() =>
result.current.selectMentionSuggestion({
pubkey: "a".repeat(64),
displayName: "Scout",
isAgent: true,
}),
);
assert.deepEqual(effects, []);
assert.equal(result.current.announcement, "");
});
Loading