mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
fix(desktop): revalidate current access and exact team members
Package existing reviewed repairs in the authorized seven-slice dependency stack. Preserved model A; experiment excluded. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
476
desktop/src/features/channels/mentionAdmissionJourney.test.mjs
Normal file
476
desktop/src/features/channels/mentionAdmissionJourney.test.mjs
Normal file
@@ -0,0 +1,476 @@
|
||||
// Admission against existing root query evidence, without membership freshness production.
|
||||
// Real mention and picker hooks; Tauri policy/classification are fixture evidence.
|
||||
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",
|
||||
});
|
||||
Object.assign(globalThis, {
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
localStorage: dom.window.localStorage,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
HTMLIFrameElement: dom.window.HTMLIFrameElement,
|
||||
MutationObserver: dom.window.MutationObserver,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
self: dom.window,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: dom.window.navigator,
|
||||
});
|
||||
dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame;
|
||||
const VIEWER = "a".repeat(64),
|
||||
AGENT = "b".repeat(64),
|
||||
OTHER = "c".repeat(64);
|
||||
const CHANNEL = "11111111-1111-4111-8111-111111111111";
|
||||
localStorage.setItem(
|
||||
"buzz-communities",
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "test",
|
||||
name: "Test",
|
||||
relayUrl: "ws://test.invalid",
|
||||
addedAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
);
|
||||
localStorage.setItem("buzz-active-community-id", "test");
|
||||
let state;
|
||||
const channel = () => ({
|
||||
id: CHANNEL,
|
||||
name: "fresh",
|
||||
channel_type: "stream",
|
||||
visibility: "open",
|
||||
description: "",
|
||||
is_member: true,
|
||||
archived_at: null,
|
||||
member_pubkeys: state.visible ? [VIEWER, AGENT] : [VIEWER],
|
||||
member_count: state.visible ? 2 : 1,
|
||||
participant_pubkeys: [],
|
||||
participants: [],
|
||||
last_message_at: null,
|
||||
ttl_seconds: null,
|
||||
ttl_deadline: null,
|
||||
});
|
||||
const rawAgent = () => ({
|
||||
pubkey: AGENT,
|
||||
owner_pubkey: state.owner,
|
||||
name: "Remote Scout",
|
||||
agent_type: "agent",
|
||||
channels: [],
|
||||
channel_ids: state.directoryVisible ? [CHANNEL] : [],
|
||||
capabilities: [],
|
||||
status: "offline",
|
||||
respond_to: state.policy,
|
||||
respond_to_allowlist: [],
|
||||
});
|
||||
const invoke = async (command, args) => {
|
||||
if (command.startsWith("plugin:event|")) return 0;
|
||||
if (command === "search_users") {
|
||||
return { users: [], next_cursor: null };
|
||||
}
|
||||
if (command === "get_identity") return { pubkey: VIEWER };
|
||||
if (command === "create_channel") return channel();
|
||||
if (command === "get_channels")
|
||||
return {
|
||||
channels: [channel()],
|
||||
hash: String(state.visible),
|
||||
last_messages: [],
|
||||
};
|
||||
if (command === "get_channel_members" && state.heldRoster)
|
||||
return state.heldRoster;
|
||||
if (command === "get_channel_members")
|
||||
return {
|
||||
members: [
|
||||
{
|
||||
pubkey: VIEWER,
|
||||
role: "owner",
|
||||
display_name: "Viewer",
|
||||
is_agent: false,
|
||||
},
|
||||
...(state.visible
|
||||
? [
|
||||
{
|
||||
pubkey: AGENT,
|
||||
role: state.role,
|
||||
display_name: "Remote Scout",
|
||||
is_agent: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
if (command === "add_channel_members") {
|
||||
assert.equal(args.channelId, CHANNEL);
|
||||
assert.equal(args.role, state.role);
|
||||
state.accepted = true;
|
||||
return state.addResult;
|
||||
}
|
||||
if (command === "sync_agents_to_active_huddle") return null;
|
||||
if (command === "list_relay_agents") {
|
||||
state.directoryCalls += 1;
|
||||
if (state.failDirectory) throw new Error("Directory unavailable");
|
||||
return state.missingDirectory ? [] : [rawAgent()];
|
||||
}
|
||||
if (command === "revalidate_relay_agents")
|
||||
return state.missingDirectory ? [] : [rawAgent()];
|
||||
if (["list_managed_agents", "list_personas", "list_teams"].includes(command))
|
||||
return [];
|
||||
if (command === "get_users_batch") return { profiles: {}, missing: [] };
|
||||
if (command === "list_archived_identities") return { archived: [] };
|
||||
throw new Error(`Unexpected IPC: ${command}`);
|
||||
};
|
||||
globalThis.__TAURI_INTERNALS__ = { invoke, transformCallback: () => 1 };
|
||||
dom.window.__TAURI_INTERNALS__ = globalThis.__TAURI_INTERNALS__;
|
||||
globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} };
|
||||
dom.window.__TAURI_EVENT_PLUGIN_INTERNALS__ =
|
||||
globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__;
|
||||
|
||||
let React,
|
||||
act,
|
||||
createRoot,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
CommunitiesProvider;
|
||||
let useMentions;
|
||||
let root, client, mention, picker;
|
||||
let useAgentAddressLockPicker, effects;
|
||||
before(async () => {
|
||||
({ useAgentAddressLockPicker } = await import(
|
||||
"@/features/messages/ui/useAgentAddressLockPicker.ts"
|
||||
));
|
||||
({ default: React, act } = await import("react"));
|
||||
({ createRoot } = await import("react-dom/client"));
|
||||
({ QueryClient, QueryClientProvider } = await import(
|
||||
"@tanstack/react-query"
|
||||
));
|
||||
({ CommunitiesProvider } = await import(
|
||||
"@/features/communities/useCommunities.tsx"
|
||||
));
|
||||
({ useMentions } = await import("@/features/messages/lib/useMentions.ts"));
|
||||
});
|
||||
function Composer() {
|
||||
mention = useMentions(state.channelId, undefined, undefined, {
|
||||
channelType: "stream",
|
||||
});
|
||||
picker = useAgentAddressLockPicker({
|
||||
mentions: mention,
|
||||
audience: {
|
||||
pubkeys: state.locked,
|
||||
addPubkey: (key) => effects.push(["pin", key]),
|
||||
removePubkey: (key) => effects.push(["remove", key]),
|
||||
},
|
||||
audienceScope: state.channelId,
|
||||
richText: { getPlainTextAndCursor: () => ({ text: "@", cursor: 1 }) },
|
||||
applyAutocompleteEdit: (edit) => effects.push(["edit", edit]),
|
||||
onAddressAgentMention: (row) => effects.push(["promote", row.pubkey]),
|
||||
onPulseAddressLock: () => {},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
async function render(withComposer = true) {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client },
|
||||
React.createElement(
|
||||
CommunitiesProvider,
|
||||
null,
|
||||
withComposer ? React.createElement(Composer) : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
// React Query notification batching may be enqueued by effects committed above.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
}
|
||||
const rows = () => mention.suggestions.filter((row) => row.pubkey === AGENT);
|
||||
async function setup(overrides = {}) {
|
||||
effects = [];
|
||||
state = {
|
||||
locked: [],
|
||||
channelId: CHANNEL,
|
||||
role: "bot",
|
||||
owner: VIEWER,
|
||||
policy: "anyone",
|
||||
accepted: false,
|
||||
visible: false,
|
||||
directoryVisible: false,
|
||||
directoryCalls: 0,
|
||||
addResult: { added: [AGENT], errors: [] },
|
||||
...overrides,
|
||||
};
|
||||
client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: Infinity },
|
||||
mutations: { retry: false, gcTime: Infinity },
|
||||
},
|
||||
});
|
||||
for (const [key, data] of [
|
||||
[["identity"], { pubkey: VIEWER }],
|
||||
[["channels"], [channel()]],
|
||||
[["managed-agents"], []],
|
||||
[["personas"], []],
|
||||
[["teams"], []],
|
||||
[["archivedIdentities"], { archived: [] }],
|
||||
])
|
||||
client.setQueryData(key, data);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await render();
|
||||
await act(async () => mention.updateMentionQuery("@", 1));
|
||||
await settle();
|
||||
}
|
||||
afterEach(async () => {
|
||||
if (root) await act(async () => root.unmount());
|
||||
client?.clear();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
after(() => dom.window.close());
|
||||
|
||||
for (const change of [
|
||||
"policy-denied",
|
||||
"late-error",
|
||||
"directory-removed",
|
||||
"member-removed",
|
||||
]) {
|
||||
test(`a retained callback cannot bind after ${change}`, async () => {
|
||||
await setup({ owner: OTHER, visible: true, directoryVisible: true });
|
||||
const staleRow = rows()[0];
|
||||
const staleInsert = mention.insertMention;
|
||||
assert.equal(staleRow.isAgent, true);
|
||||
assert.equal(mention.canSelectMention(staleRow), true);
|
||||
if (change === "policy-denied") state.policy = "owner-only";
|
||||
if (change === "late-error") state.failDirectory = true;
|
||||
if (change.endsWith("removed")) state.missingDirectory = true;
|
||||
if (change === "member-removed") {
|
||||
state.visible = false;
|
||||
await act(async () =>
|
||||
client.invalidateQueries({
|
||||
queryKey: ["channels", CHANNEL, "members"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
await act(async () =>
|
||||
client.invalidateQueries({ queryKey: ["relay-agents"] }),
|
||||
);
|
||||
await settle();
|
||||
let edit;
|
||||
await act(async () => {
|
||||
edit = staleInsert(staleRow, 1);
|
||||
});
|
||||
assert.equal(
|
||||
edit.insertText,
|
||||
"",
|
||||
"old actionable row must not establish intent",
|
||||
);
|
||||
assert.deepEqual(mention.knownNames, []);
|
||||
});
|
||||
}
|
||||
|
||||
test("only an exact current target can be selected", async () => {
|
||||
await setup({ visible: true, directoryVisible: true });
|
||||
assert.equal(mention.canSelectMention(rows()[0]), true);
|
||||
for (const target of [
|
||||
{ displayName: "Remote Scout" },
|
||||
{ displayName: "Remote Scout", pubkey: OTHER },
|
||||
]) {
|
||||
assert.equal(mention.canSelectMention(target), false);
|
||||
let edit;
|
||||
await act(async () => {
|
||||
edit = mention.insertMention(target, 1);
|
||||
});
|
||||
assert.equal(edit.insertText, "");
|
||||
}
|
||||
assert.deepEqual(mention.knownNames, []);
|
||||
});
|
||||
|
||||
// These exercise the real sibling picker + mention hook, not an admission stub.
|
||||
test("retained explicit pin rejects latest policy denial without draft effects", async () => {
|
||||
await setup({ owner: OTHER, visible: true, directoryVisible: true });
|
||||
const row = rows()[0],
|
||||
oldPin = picker.toggleAlwaysAddressAgent;
|
||||
state.policy = "owner-only";
|
||||
await act(async () =>
|
||||
client.invalidateQueries({ queryKey: ["relay-agents"] }),
|
||||
);
|
||||
await settle();
|
||||
assert.equal(rows().length, 0);
|
||||
await act(async () => oldPin(row));
|
||||
assert.deepEqual(effects, []);
|
||||
assert.deepEqual(mention.knownNames, []);
|
||||
});
|
||||
|
||||
for (const returnToOrigin of [false, true]) {
|
||||
test(`retained pin and insertion reject another scope visit (return=${returnToOrigin})`, async () => {
|
||||
await setup({ visible: true, directoryVisible: true });
|
||||
const row = rows()[0],
|
||||
oldPin = picker.toggleAlwaysAddressAgent;
|
||||
const oldInsert = mention.insertMention;
|
||||
const oldSelect = picker.selectMentionSuggestion;
|
||||
state.channelId = "22222222-2222-4222-8222-222222222222";
|
||||
await render();
|
||||
if (returnToOrigin) {
|
||||
state.channelId = CHANNEL;
|
||||
await render();
|
||||
}
|
||||
let edit;
|
||||
await act(async () => {
|
||||
oldPin(row);
|
||||
oldSelect(row);
|
||||
edit = oldInsert(row, 1);
|
||||
});
|
||||
assert.deepEqual(effects, []);
|
||||
assert.equal(edit.insertText, "");
|
||||
assert.deepEqual(mention.knownNames, []);
|
||||
});
|
||||
}
|
||||
|
||||
test("latest locked state permits removal after denial, including a retained toggle", async () => {
|
||||
await setup({ owner: OTHER, visible: true, directoryVisible: true });
|
||||
const row = rows()[0],
|
||||
oldPin = picker.toggleAlwaysAddressAgent;
|
||||
state.locked = [AGENT];
|
||||
state.policy = "owner-only";
|
||||
await act(async () =>
|
||||
client.invalidateQueries({ queryKey: ["relay-agents"] }),
|
||||
);
|
||||
await settle();
|
||||
assert.equal(mention.canSelectMention(row), false);
|
||||
await act(async () => oldPin(row));
|
||||
assert.ok(
|
||||
effects.some(([effect, key]) => effect === "remove" && key === AGENT),
|
||||
);
|
||||
assert.ok(
|
||||
effects.every(
|
||||
([effect, edit]) =>
|
||||
effect === "remove" || (effect === "edit" && edit.insertText === ""),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(mention.knownNames, []);
|
||||
});
|
||||
|
||||
test("retained team cannot bind a removed exact member", async () => {
|
||||
await setup({ visible: true, directoryVisible: true });
|
||||
const persona = {
|
||||
id: "review-scout",
|
||||
displayName: "Remote Scout",
|
||||
isActive: true,
|
||||
};
|
||||
const team = {
|
||||
id: "team-review",
|
||||
name: "Review Team",
|
||||
isBuiltin: false,
|
||||
personaIds: [persona.id],
|
||||
};
|
||||
await act(async () => {
|
||||
client.setQueryData(["personas"], [persona]);
|
||||
client.setQueryData(
|
||||
["managed-agents"],
|
||||
[
|
||||
{
|
||||
pubkey: AGENT,
|
||||
name: "Remote Scout",
|
||||
personaId: persona.id,
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
);
|
||||
client.setQueryData(["teams"], [team]);
|
||||
});
|
||||
await settle();
|
||||
const row = mention.suggestions.find((s) => s.kind === "team");
|
||||
assert.ok(row, JSON.stringify(mention.suggestions));
|
||||
assert.equal(row.teamMembers[0].pubkey, AGENT);
|
||||
const old = mention.insertMention;
|
||||
state.missingDirectory = true;
|
||||
await act(async () => client.setQueryData(["managed-agents"], []));
|
||||
await act(async () =>
|
||||
client.invalidateQueries({ queryKey: ["relay-agents"] }),
|
||||
);
|
||||
await settle();
|
||||
assert.equal(
|
||||
mention.suggestions.some((s) => s.pubkey === AGENT),
|
||||
false,
|
||||
);
|
||||
assert.ok(mention.suggestions.find((s) => s.kind === "team"));
|
||||
let edit;
|
||||
await act(async () => {
|
||||
edit = old(row, 1);
|
||||
});
|
||||
assert.deepEqual(mention.knownNames, []);
|
||||
assert.deepEqual(mention.getDraftMentionRefs(edit.insertText), []);
|
||||
assert.equal(
|
||||
edit.insertText,
|
||||
"",
|
||||
"removed exact team member must not establish intent",
|
||||
);
|
||||
});
|
||||
|
||||
test("duplicate team members cannot mask a recipient set change", async () => {
|
||||
await setup({ visible: true, directoryVisible: true });
|
||||
const personas = ["one", "two"].map((id) => ({
|
||||
id,
|
||||
displayName: id,
|
||||
isActive: true,
|
||||
}));
|
||||
const team = {
|
||||
id: "duplicates",
|
||||
name: "Duplicates",
|
||||
isBuiltin: false,
|
||||
personaIds: ["one", "one"],
|
||||
};
|
||||
await act(async () => {
|
||||
client.setQueryData(["personas"], personas);
|
||||
client.setQueryData(
|
||||
["managed-agents"],
|
||||
personas.map((p, i) => ({
|
||||
pubkey: i ? OTHER : AGENT,
|
||||
name: p.displayName,
|
||||
personaId: p.id,
|
||||
status: "running",
|
||||
})),
|
||||
);
|
||||
client.setQueryData(["teams"], [team]);
|
||||
});
|
||||
await settle();
|
||||
const row = mention.suggestions.find((s) => s.kind === "team");
|
||||
assert.ok(row);
|
||||
assert.equal(
|
||||
new Set(row.teamMembers.map((m) => m.pubkey ?? m.personaId)).size,
|
||||
1,
|
||||
);
|
||||
const insert = mention.insertMention;
|
||||
await act(async () =>
|
||||
client.setQueryData(["teams"], [{ ...team, personaIds: ["one", "two"] }]),
|
||||
);
|
||||
await settle();
|
||||
assert.equal(
|
||||
new Set(
|
||||
mention.suggestions
|
||||
.find((s) => s.kind === "team")
|
||||
.teamMembers.map((m) => m.pubkey ?? m.personaId),
|
||||
).size,
|
||||
2,
|
||||
);
|
||||
let edit;
|
||||
await act(async () => {
|
||||
edit = insert(row, 1);
|
||||
});
|
||||
assert.equal(edit.insertText, "");
|
||||
assert.deepEqual(mention.knownNames, []);
|
||||
assert.deepEqual(mention.getDraftMentionRefs(edit.insertText), []);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
buildTeamMentionCandidates,
|
||||
formatTeamMention,
|
||||
sameTeamMentionRecipients,
|
||||
} from "./mentionCandidates.ts";
|
||||
|
||||
function persona(id, displayName, isActive = true) {
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { MentionAction } from "./mentionPresentation";
|
||||
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
|
||||
import type {
|
||||
AgentPersona,
|
||||
@@ -5,7 +6,7 @@ import type {
|
||||
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;
|
||||
@@ -33,6 +34,7 @@ export type TeamMentionMember = {
|
||||
};
|
||||
|
||||
export type MentionCandidate = {
|
||||
action?: MentionAction;
|
||||
kind: "identity" | "persona" | "team";
|
||||
pubkey?: string;
|
||||
personaId?: string;
|
||||
@@ -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
desktop/src/features/messages/lib/mentionPresentation.ts
Normal file
6
desktop/src/features/messages/lib/mentionPresentation.ts
Normal file
@@ -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";
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isMentionActionable } from "./mentionPresentation";
|
||||
import * as React from "react";
|
||||
import {
|
||||
useManagedAgentsQuery,
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
appendUniqueName,
|
||||
buildTeamMentionCandidates,
|
||||
formatTeamMention,
|
||||
sameTeamMentionRecipients,
|
||||
type MentionCandidate,
|
||||
} from "./mentionCandidates";
|
||||
import { buildMentionCandidates } from "./buildMentionCandidates";
|
||||
@@ -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,
|
||||
@@ -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 }) => {
|
||||
@@ -837,6 +902,7 @@ export function useMentions(
|
||||
],
|
||||
);
|
||||
return {
|
||||
canSelectMention,
|
||||
cancelMentionAutocomplete,
|
||||
clearMentions,
|
||||
getDefaultAgentSuggestion,
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
|
||||
@@ -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("@"),
|
||||
};
|
||||
@@ -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),
|
||||
@@ -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("@"),
|
||||
};
|
||||
@@ -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: () => ({
|
||||
@@ -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: () => ({
|
||||
@@ -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,
|
||||
@@ -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];
|
||||
@@ -443,6 +450,7 @@ test("restoring before authored text preserves its selection", async () => {
|
||||
mentions: {
|
||||
getDraftMentionRefs: () => [],
|
||||
getMentionDisplayName: () => "Morgarita",
|
||||
canSelectMention: () => true,
|
||||
registerMentionPubkey: () => {},
|
||||
},
|
||||
onPulseAddressLock: () => {},
|
||||
@@ -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];
|
||||
@@ -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: () => ({
|
||||
@@ -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: () => ({
|
||||
@@ -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];
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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, "");
|
||||
});
|
||||
|
||||
@@ -91,6 +91,10 @@ export function useAgentAddressLockPicker({
|
||||
() => new Set(audience.pubkeys),
|
||||
[audience.pubkeys],
|
||||
);
|
||||
// A retained callback belongs to one audience visit, including A -> B -> A.
|
||||
const scopeOwner = React.useMemo(() => ({ audienceScope }), [audienceScope]);
|
||||
const currentAudience = React.useRef({ scopeOwner, lockedAgentPubkeys });
|
||||
currentAudience.current = { scopeOwner, lockedAgentPubkeys };
|
||||
const unpinnedAgentPubkeysRef = React.useRef(new Set<string>());
|
||||
const unpinnedAudienceScopeRef = React.useRef(audienceScope);
|
||||
if (unpinnedAudienceScopeRef.current !== audienceScope) {
|
||||
@@ -246,14 +250,21 @@ export function useAgentAddressLockPicker({
|
||||
const toggleAlwaysAddressAgent = React.useCallback(
|
||||
(suggestion: MentionSuggestion) => {
|
||||
const pubkey = normalizePubkey(suggestion.pubkey ?? "");
|
||||
if (!audienceScope || !pubkey || !suggestion.isAgent) return;
|
||||
if (
|
||||
!audienceScope ||
|
||||
!pubkey ||
|
||||
!suggestion.isAgent ||
|
||||
currentAudience.current.scopeOwner !== scopeOwner
|
||||
)
|
||||
return;
|
||||
|
||||
if (lockedAgentPubkeys.has(pubkey)) {
|
||||
if (currentAudience.current.lockedAgentPubkeys.has(pubkey)) {
|
||||
removeAddressedAgentMentions(pubkey);
|
||||
setAnnouncement(
|
||||
`Stopped automatically mentioning ${suggestion.displayName}`,
|
||||
);
|
||||
} else {
|
||||
if (!mentions.canSelectMention(suggestion)) return;
|
||||
unpinnedAgentPubkeysRef.current.delete(pubkey);
|
||||
const label =
|
||||
mentions.registerMentionPubkey(suggestion.displayName, pubkey, {
|
||||
@@ -320,13 +331,14 @@ export function useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit,
|
||||
audience.addPubkey,
|
||||
audienceScope,
|
||||
lockedAgentPubkeys,
|
||||
mentions.getDraftMentionRefs,
|
||||
mentions.isInlineMentionSelection,
|
||||
mentions.isMentionOpen,
|
||||
mentions.mentionStartIndex,
|
||||
mentions.openMentionPicker,
|
||||
mentions.registerMentionPubkey,
|
||||
mentions.canSelectMention,
|
||||
scopeOwner,
|
||||
onAddressAgentMention,
|
||||
onImplicitPrefixInserted,
|
||||
onPulseAddressLock,
|
||||
@@ -338,14 +350,19 @@ export function useAgentAddressLockPicker({
|
||||
|
||||
const selectMentionSuggestion = React.useCallback(
|
||||
(suggestion: MentionSuggestion) => {
|
||||
if (currentAudience.current.scopeOwner !== scopeOwner) return;
|
||||
const { cursor } = richText.getPlainTextAndCursor();
|
||||
const wasInlineSelection = mentions.isInlineMentionSelection();
|
||||
const edit = mentions.insertMention(suggestion, cursor);
|
||||
// Rejected stale selections must not establish automatic audience intent.
|
||||
if (!edit.insertText) return;
|
||||
const pubkey = normalizePubkey(suggestion.pubkey ?? "");
|
||||
if (suggestion.isAgent && pubkey && audienceScope) {
|
||||
const { cursor } = richText.getPlainTextAndCursor();
|
||||
const wasUnpinned =
|
||||
!lockedAgentPubkeys.has(pubkey) &&
|
||||
unpinnedAgentPubkeysRef.current.has(pubkey);
|
||||
if (mentions.isInlineMentionSelection() || wasUnpinned) {
|
||||
applyAutocompleteEdit(mentions.insertMention(suggestion, cursor));
|
||||
if (wasInlineSelection || wasUnpinned) {
|
||||
applyAutocompleteEdit(edit);
|
||||
trackMentionAddressedAgent(pubkey);
|
||||
onAutoPinAgentMention?.(suggestion, {
|
||||
reinstateExcluded: !wasUnpinned,
|
||||
@@ -353,7 +370,7 @@ export function useAgentAddressLockPicker({
|
||||
return;
|
||||
}
|
||||
|
||||
applyAutocompleteEdit(mentions.insertMention(suggestion, cursor));
|
||||
applyAutocompleteEdit(edit);
|
||||
if (!lockedAgentPubkeys.has(pubkey)) {
|
||||
trackMentionAddressedAgent(pubkey);
|
||||
if (onAddressAgentMention) {
|
||||
@@ -369,8 +386,7 @@ export function useAgentAddressLockPicker({
|
||||
return;
|
||||
}
|
||||
|
||||
const { cursor } = richText.getPlainTextAndCursor();
|
||||
applyAutocompleteEdit(mentions.insertMention(suggestion, cursor));
|
||||
applyAutocompleteEdit(edit);
|
||||
},
|
||||
[
|
||||
applyAutocompleteEdit,
|
||||
@@ -379,6 +395,7 @@ export function useAgentAddressLockPicker({
|
||||
lockedAgentPubkeys,
|
||||
mentions.isInlineMentionSelection,
|
||||
mentions.insertMention,
|
||||
scopeOwner,
|
||||
onAddressAgentMention,
|
||||
onAutoPinAgentMention,
|
||||
onPulseAddressLock,
|
||||
|
||||
Reference in New Issue
Block a user