mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): expand owned team mentions (#1918)
This commit is contained in:
@@ -53,6 +53,7 @@ export default defineConfig({
|
||||
"**/composer-link-shortcut.spec.ts",
|
||||
"**/composer-tooltip-dismiss.spec.ts",
|
||||
"**/mentions.spec.ts",
|
||||
"**/team-mentions.spec.ts",
|
||||
"**/relay-reconnect.spec.ts",
|
||||
"**/relay-reconnect-affordance.spec.ts",
|
||||
"**/workflows.spec.ts",
|
||||
|
||||
@@ -67,3 +67,43 @@ test("flushMentionDebounce returns null for an empty fresh query", () => {
|
||||
|
||||
assert.equal(flushed, null);
|
||||
});
|
||||
|
||||
test("flushMentionDebounce preserves a team expansion selected with Enter", () => {
|
||||
const teamMembers = [
|
||||
{
|
||||
displayName: "Planner",
|
||||
kind: "persona",
|
||||
personaId: "planner",
|
||||
},
|
||||
{
|
||||
displayName: "Builder",
|
||||
kind: "identity",
|
||||
personaId: "builder",
|
||||
pubkey: "c".repeat(64),
|
||||
},
|
||||
];
|
||||
const flushed = flushMentionDebounce({
|
||||
debounceTimerRef: ref(setTimeout(() => {}, 1000)),
|
||||
latestValueRef: ref("Ask @launch"),
|
||||
latestCursorRef: ref("Ask @launch".length),
|
||||
searchableNamesLowerRef: ref(["launch team"]),
|
||||
candidates: [
|
||||
candidate({
|
||||
kind: "team",
|
||||
displayName: "Launch Team",
|
||||
isAgent: true,
|
||||
isMember: false,
|
||||
pubkey: undefined,
|
||||
teamId: "launch",
|
||||
teamMembers,
|
||||
}),
|
||||
],
|
||||
activePersonaIds: new Set(),
|
||||
channelType: "group",
|
||||
});
|
||||
|
||||
assert.equal(flushed?.type, "match");
|
||||
assert.equal(flushed?.suggestion.kind, "team");
|
||||
assert.deepEqual(flushed?.suggestion.teamMembers, teamMembers);
|
||||
assert.equal(flushed?.suggestion.notInChannel, false);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,8 @@ function escapeRegExp(str: string): string {
|
||||
/**
|
||||
* Check whether `text` contains an @mention of `name`.
|
||||
*
|
||||
* Matches `@Name` preceded by start-of-string, whitespace, markdown
|
||||
* Matches `@Name` preceded by start-of-string, whitespace, an opening
|
||||
* parenthesis (for team expansions), markdown
|
||||
* bold/italic markers (`*`, `**`, `***`, `_`, `__`, `___`), or spoiler
|
||||
* delimiters (`||`). This handles the case where a mention is pasted from the
|
||||
* chat area and TipTap's Bold extension wraps it in bold marks (font-weight >=
|
||||
@@ -19,7 +20,7 @@ function escapeRegExp(str: string): string {
|
||||
export function hasMention(text: string, name: string): boolean {
|
||||
const escaped = escapeRegExp(name);
|
||||
const pattern = new RegExp(
|
||||
`(?:^|\\s|[*_]{1,3}|\\|\\|)@${escaped}(?=\\|\\||[\\s,;.!?:)\\]}*_]|$)`,
|
||||
`(?:^|\\s|\\(|[*_]{1,3}|\\|\\|)@${escaped}(?=\\|\\||[\\s,;.!?:)\\]}*_]|$)`,
|
||||
"i",
|
||||
);
|
||||
return pattern.test(text);
|
||||
|
||||
172
desktop/src/features/messages/lib/mentionCandidates.test.mjs
Normal file
172
desktop/src/features/messages/lib/mentionCandidates.test.mjs
Normal file
@@ -0,0 +1,172 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildTeamMentionCandidates,
|
||||
formatTeamMention,
|
||||
} from "./mentionCandidates.ts";
|
||||
|
||||
function persona(id, displayName, isActive = true) {
|
||||
return {
|
||||
id,
|
||||
displayName,
|
||||
avatarUrl: null,
|
||||
systemPrompt: `${displayName} prompt`,
|
||||
runtime: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
namePool: [],
|
||||
isBuiltIn: false,
|
||||
isActive,
|
||||
envVars: {},
|
||||
respondTo: null,
|
||||
respondToAllowlist: [],
|
||||
parallelism: null,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function team(id, personaIds, overrides = {}) {
|
||||
return {
|
||||
id,
|
||||
name: "Launch Team",
|
||||
description: null,
|
||||
instructions: null,
|
||||
personaIds,
|
||||
isBuiltin: false,
|
||||
sourceDir: null,
|
||||
isSymlink: false,
|
||||
symlinkTarget: null,
|
||||
version: null,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function identity(personaId, displayName, overrides = {}) {
|
||||
return {
|
||||
kind: "identity",
|
||||
personaId,
|
||||
displayName,
|
||||
isAgent: true,
|
||||
isMember: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("team mentions preserve team order and prefer concrete managed agents", () => {
|
||||
const personas = [
|
||||
persona("planner", "Planner"),
|
||||
persona("builder", "Builder"),
|
||||
persona("reviewer", "Reviewer"),
|
||||
];
|
||||
const candidates = [
|
||||
identity("builder", "Build Bot", {
|
||||
isManagedAgent: true,
|
||||
pubkey: "2".repeat(64),
|
||||
}),
|
||||
identity("planner", "Plan Bot", {
|
||||
isManagedAgent: true,
|
||||
pubkey: "1".repeat(64),
|
||||
}),
|
||||
identity("planner", "Planner in channel", {
|
||||
isMember: true,
|
||||
pubkey: "3".repeat(64),
|
||||
}),
|
||||
];
|
||||
|
||||
const [suggestion] = buildTeamMentionCandidates(
|
||||
[team("launch", ["planner", "builder", "reviewer"])],
|
||||
personas,
|
||||
candidates,
|
||||
);
|
||||
|
||||
assert.equal(suggestion.kind, "team");
|
||||
assert.deepEqual(suggestion.teamMembers, [
|
||||
{
|
||||
displayName: "Planner in channel",
|
||||
kind: "identity",
|
||||
personaId: "planner",
|
||||
pubkey: "3".repeat(64),
|
||||
},
|
||||
{
|
||||
displayName: "Build Bot",
|
||||
kind: "identity",
|
||||
personaId: "builder",
|
||||
pubkey: "2".repeat(64),
|
||||
},
|
||||
{
|
||||
displayName: "Reviewer",
|
||||
kind: "persona",
|
||||
personaId: "reviewer",
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
formatTeamMention(suggestion.displayName, suggestion.teamMembers),
|
||||
"Launch Team(@Planner in channel @Build Bot @Reviewer) ",
|
||||
);
|
||||
});
|
||||
|
||||
test("only complete, owned teams with mentionable members are suggested", () => {
|
||||
const active = persona("active", "Active");
|
||||
const inactive = persona("inactive", "Inactive", false);
|
||||
const teams = [
|
||||
team("owned", ["active"]),
|
||||
team("builtin", ["active"], { isBuiltin: true }),
|
||||
team("missing", ["missing"]),
|
||||
team("inactive", ["inactive"]),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
buildTeamMentionCandidates(teams, [active, inactive], []).map(
|
||||
(candidate) => candidate.teamId,
|
||||
),
|
||||
["owned"],
|
||||
);
|
||||
});
|
||||
|
||||
test("teams with duplicate identity display names are not suggested", () => {
|
||||
const personas = [
|
||||
persona("builder-one", "First"),
|
||||
persona("builder-two", "Second"),
|
||||
];
|
||||
const candidates = [
|
||||
identity("builder-one", "Builder", { pubkey: "1".repeat(64) }),
|
||||
identity("builder-two", "Builder", { pubkey: "2".repeat(64) }),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
buildTeamMentionCandidates(
|
||||
[team("duplicate-identities", ["builder-one", "builder-two"])],
|
||||
personas,
|
||||
candidates,
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("teams with identity and persona display-name collisions are not suggested", () => {
|
||||
const personas = [
|
||||
persona("managed-builder", "Managed Builder"),
|
||||
persona("persona-builder", "builder"),
|
||||
];
|
||||
const candidates = [
|
||||
identity("managed-builder", "Builder", { pubkey: "1".repeat(64) }),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
buildTeamMentionCandidates(
|
||||
[
|
||||
team("identity-persona-collision", [
|
||||
"managed-builder",
|
||||
"persona-builder",
|
||||
]),
|
||||
],
|
||||
personas,
|
||||
candidates,
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
132
desktop/src/features/messages/lib/mentionCandidates.ts
Normal file
132
desktop/src/features/messages/lib/mentionCandidates.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
|
||||
import type { AgentPersona, AgentTeam, ChannelRole } from "@/shared/api/types";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export type TeamMentionMember = {
|
||||
displayName: string;
|
||||
kind: "identity" | "persona";
|
||||
personaId?: string;
|
||||
pubkey?: string;
|
||||
};
|
||||
|
||||
export type MentionCandidate = {
|
||||
kind: "identity" | "persona" | "team";
|
||||
pubkey?: string;
|
||||
personaId?: string;
|
||||
teamId?: string;
|
||||
teamMembers?: TeamMentionMember[];
|
||||
displayName: string | null;
|
||||
avatarUrl?: string | null;
|
||||
isMember: boolean;
|
||||
role?: ChannelRole | null;
|
||||
personaName?: string | null;
|
||||
secondaryLabel?: string | null;
|
||||
ownerPubkey?: string | null;
|
||||
isAgent: boolean;
|
||||
isManagedAgent?: boolean;
|
||||
isGlobalSearchResult?: boolean;
|
||||
};
|
||||
|
||||
export function mentionCandidateLabel(candidate: MentionCandidate) {
|
||||
return (
|
||||
candidate.displayName ??
|
||||
(candidate.pubkey ? truncatePubkey(candidate.pubkey) : "agent")
|
||||
);
|
||||
}
|
||||
|
||||
export function globalSearchIdentityKey(candidate: MentionCandidate) {
|
||||
if (
|
||||
!candidate.isGlobalSearchResult ||
|
||||
candidate.isMember ||
|
||||
candidate.isAgent
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = candidate.displayName?.trim().toLowerCase();
|
||||
if (!label) return null;
|
||||
|
||||
const secondaryLabel = candidate.secondaryLabel?.trim().toLowerCase() ?? "";
|
||||
return `global-person:${label}:${secondaryLabel}`;
|
||||
}
|
||||
|
||||
function findTeamMemberTarget(
|
||||
persona: AgentPersona,
|
||||
candidates: readonly MentionCandidate[],
|
||||
): TeamMentionMember | null {
|
||||
const linked = candidates
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.kind !== "team" && candidate.personaId === persona.id,
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const rank = (candidate: MentionCandidate) => {
|
||||
if (candidate.kind === "identity" && candidate.isMember) return 0;
|
||||
if (candidate.kind === "identity" && candidate.isManagedAgent) return 1;
|
||||
if (candidate.kind === "identity") return 2;
|
||||
return 3;
|
||||
};
|
||||
return rank(left) - rank(right);
|
||||
})[0];
|
||||
|
||||
if (linked) {
|
||||
return {
|
||||
displayName: linked.displayName?.trim() || persona.displayName,
|
||||
kind: linked.kind === "identity" ? "identity" : "persona",
|
||||
personaId: linked.personaId,
|
||||
pubkey: linked.pubkey,
|
||||
};
|
||||
}
|
||||
|
||||
return persona.isActive
|
||||
? {
|
||||
displayName: persona.displayName,
|
||||
kind: "persona",
|
||||
personaId: persona.id,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Build autocomplete entries for editable, locally owned teams. */
|
||||
export function buildTeamMentionCandidates(
|
||||
teams: readonly AgentTeam[],
|
||||
personas: AgentPersona[],
|
||||
candidates: readonly MentionCandidate[],
|
||||
): MentionCandidate[] {
|
||||
return teams.flatMap((team) => {
|
||||
if (team.isBuiltin || !team.name.trim()) return [];
|
||||
|
||||
const resolution = resolveTeamPersonas(team, personas);
|
||||
if (!resolution.isUsable) return [];
|
||||
|
||||
const teamMembers = resolution.resolvedPersonas
|
||||
.map((persona) => findTeamMemberTarget(persona, candidates))
|
||||
.filter((member): member is TeamMentionMember => member !== null);
|
||||
if (teamMembers.length !== resolution.resolvedPersonas.length) return [];
|
||||
|
||||
const mentionNames = new Set<string>();
|
||||
for (const member of teamMembers) {
|
||||
const mentionName = member.displayName.trim().toLowerCase();
|
||||
if (mentionNames.has(mentionName)) return [];
|
||||
mentionNames.add(mentionName);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
kind: "team" as const,
|
||||
teamId: team.id,
|
||||
teamMembers,
|
||||
displayName: team.name.trim(),
|
||||
isMember: false,
|
||||
isAgent: true,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function formatTeamMention(
|
||||
teamName: string,
|
||||
members: readonly TeamMentionMember[],
|
||||
) {
|
||||
return `${teamName}(${members.map((member) => `@${member.displayName}`).join(" ")}) `;
|
||||
}
|
||||
@@ -59,6 +59,18 @@ test("matches @mention after whitespace", () => {
|
||||
assert.equal(matches[0].match, "@bob");
|
||||
});
|
||||
|
||||
test("matches the first mention in a parenthesized team expansion", () => {
|
||||
const patterns = buildHighlightPatterns(["Planner", "Builder"], []);
|
||||
const matches = findHighlightMatches(
|
||||
"Launch Team(@Planner @Builder)",
|
||||
patterns,
|
||||
);
|
||||
assert.deepEqual(
|
||||
matches.map((match) => match.match),
|
||||
["@Planner", "@Builder"],
|
||||
);
|
||||
});
|
||||
|
||||
test("does not match @mention embedded in a word", () => {
|
||||
const patterns = buildHighlightPatterns(["bob"], []);
|
||||
const matches = findHighlightMatches("email@bob.com", patterns);
|
||||
|
||||
@@ -106,7 +106,10 @@ export function buildHighlightPatterns(
|
||||
n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||
);
|
||||
patterns.push(
|
||||
new RegExp(`(?:^|(?<=\\s))@(${escapedNames.join("|")})(?=\\W|$)`, "gi"),
|
||||
new RegExp(
|
||||
`(?:^|(?<=[\\s(]))@(${escapedNames.join("|")})(?=\\W|$)`,
|
||||
"gi",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -120,3 +120,22 @@ test("rankMentionCandidates: active persona-backed non-members outrank other non
|
||||
["5".repeat(64), OTHER_BRAIN_PUBKEY],
|
||||
);
|
||||
});
|
||||
|
||||
test("rankMentionCandidates: owned teams rank with runnable personas", () => {
|
||||
const remoteAgent = candidate({
|
||||
displayName: "Launch Agent",
|
||||
isAgent: true,
|
||||
});
|
||||
const team = candidate({
|
||||
kind: "team",
|
||||
displayName: "Launch Team",
|
||||
pubkey: undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
rankMentionCandidates([remoteAgent, team], "launch").map(
|
||||
(item) => item.candidate.kind,
|
||||
),
|
||||
["team", "identity"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ export type MentionCandidateForRanking = {
|
||||
displayName: string | null;
|
||||
isAgent: boolean;
|
||||
isMember: boolean;
|
||||
kind: "identity" | "persona";
|
||||
kind: "identity" | "persona" | "team";
|
||||
personaId?: string | null;
|
||||
personaName?: string | null;
|
||||
pubkey?: string;
|
||||
@@ -26,6 +26,7 @@ function getMentionCandidateGroupRank(
|
||||
if (candidate.isMember) return 0;
|
||||
|
||||
const isRunnablePersona =
|
||||
candidate.kind === "team" ||
|
||||
candidate.kind === "persona" ||
|
||||
(candidate.personaId ? activePersonaIds.has(candidate.personaId) : false);
|
||||
if (isRunnablePersona) return 1;
|
||||
|
||||
@@ -3,11 +3,14 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { formatOwnerLabel } from "@/features/profile/lib/identity";
|
||||
import type { ChannelRole, ChannelType } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import type { TeamMentionMember } from "./mentionCandidates";
|
||||
|
||||
export type MentionSuggestionCandidate = {
|
||||
kind: "identity" | "persona";
|
||||
kind: "identity" | "persona" | "team";
|
||||
pubkey?: string;
|
||||
personaId?: string | null;
|
||||
teamId?: string;
|
||||
teamMembers?: TeamMentionMember[];
|
||||
avatarUrl?: string | null;
|
||||
isAgent: boolean;
|
||||
isMember: boolean;
|
||||
@@ -38,6 +41,8 @@ export function mapMentionCandidateToSuggestion(opts: {
|
||||
return {
|
||||
pubkey: candidate.pubkey,
|
||||
personaId: candidate.personaId ?? undefined,
|
||||
teamId: candidate.teamId,
|
||||
teamMembers: candidate.teamMembers,
|
||||
kind: candidate.kind,
|
||||
displayName: label,
|
||||
avatarUrl:
|
||||
@@ -47,7 +52,10 @@ export function mapMentionCandidateToSuggestion(opts: {
|
||||
: null) ??
|
||||
null,
|
||||
isAgent: candidate.isAgent,
|
||||
notInChannel: channelType !== "dm" && candidate.isMember === false,
|
||||
notInChannel:
|
||||
candidate.kind !== "team" &&
|
||||
channelType !== "dm" &&
|
||||
candidate.isMember === false,
|
||||
ownerLabel,
|
||||
role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,11 @@ test("matches @Name after whitespace", () => {
|
||||
assert.equal(hasMention("hey @Alice", "Alice"), true);
|
||||
});
|
||||
|
||||
test("matches the first member in a parenthesized team expansion", () => {
|
||||
assert.equal(hasMention("Launch Team(@Planner @Builder)", "Planner"), true);
|
||||
assert.equal(hasMention("Launch Team(@Planner @Builder)", "Builder"), true);
|
||||
});
|
||||
|
||||
test("matches @Name at end of string", () => {
|
||||
assert.equal(hasMention("hello @Alice", "Alice"), true);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useManagedAgentsQuery,
|
||||
usePersonasQuery,
|
||||
useRelayAgentsQuery,
|
||||
useTeamsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import {
|
||||
useChannelMembersQuery,
|
||||
@@ -27,63 +28,28 @@ import type { AutocompleteEdit } from "./useRichTextEditor";
|
||||
import type {
|
||||
AgentPersona,
|
||||
ChannelMember,
|
||||
ChannelRole,
|
||||
ChannelType,
|
||||
UserSearchResult,
|
||||
} from "@/shared/api/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
|
||||
import { flushMentionDebounce } from "./flushMentionDebounce";
|
||||
import { hasMention } from "./hasMention";
|
||||
import { rankMentionCandidates } from "./mentionRanking";
|
||||
import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping";
|
||||
import {
|
||||
buildTeamMentionCandidates,
|
||||
formatTeamMention,
|
||||
globalSearchIdentityKey,
|
||||
type MentionCandidate,
|
||||
mentionCandidateLabel,
|
||||
} from "./mentionCandidates";
|
||||
|
||||
const MENTION_DEBOUNCE_MS = 120;
|
||||
const MENTION_SUGGESTION_LIMIT = 50;
|
||||
|
||||
type MentionCandidate = {
|
||||
kind: "identity" | "persona";
|
||||
pubkey?: string;
|
||||
personaId?: string;
|
||||
displayName: string | null;
|
||||
avatarUrl?: string | null;
|
||||
isMember: boolean;
|
||||
role?: ChannelRole | null;
|
||||
personaName?: string | null;
|
||||
secondaryLabel?: string | null;
|
||||
ownerPubkey?: string | null;
|
||||
isAgent: boolean;
|
||||
isManagedAgent?: boolean;
|
||||
isGlobalSearchResult?: boolean;
|
||||
};
|
||||
|
||||
function mentionCandidateLabel(candidate: MentionCandidate) {
|
||||
return (
|
||||
candidate.displayName ??
|
||||
(candidate.pubkey ? truncatePubkey(candidate.pubkey) : "agent")
|
||||
);
|
||||
}
|
||||
|
||||
function globalSearchIdentityKey(candidate: MentionCandidate) {
|
||||
if (
|
||||
!candidate.isGlobalSearchResult ||
|
||||
candidate.isMember ||
|
||||
candidate.isAgent
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = candidate.displayName?.trim().toLowerCase();
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const secondaryLabel = candidate.secondaryLabel?.trim().toLowerCase() ?? "";
|
||||
return `global-person:${label}:${secondaryLabel}`;
|
||||
}
|
||||
|
||||
export type PersonaMentionTarget = {
|
||||
displayName: string;
|
||||
persona: AgentPersona;
|
||||
@@ -140,6 +106,7 @@ export function useMentions(
|
||||
const relayAgentsQuery = useRelayAgentsQuery();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const personasQuery = usePersonasQuery();
|
||||
const teamsQuery = useTeamsQuery();
|
||||
const managedAgentDirectoryReady =
|
||||
managedAgentsQuery.data !== undefined ||
|
||||
!managedAgentsQuery.isLoading ||
|
||||
@@ -462,6 +429,18 @@ export function useMentions(
|
||||
relayAgentsQuery.data,
|
||||
]);
|
||||
|
||||
const mentionCandidatesWithTeams = React.useMemo(
|
||||
() => [
|
||||
...mentionCandidates,
|
||||
...buildTeamMentionCandidates(
|
||||
teamsQuery.data ?? [],
|
||||
personasQuery.data ?? [],
|
||||
mentionCandidates,
|
||||
),
|
||||
],
|
||||
[mentionCandidates, personasQuery.data, teamsQuery.data],
|
||||
);
|
||||
|
||||
const ownerPubkeys = React.useMemo(
|
||||
() => [
|
||||
...new Set(
|
||||
@@ -480,7 +459,7 @@ export function useMentions(
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const candidate of mentionCandidates) {
|
||||
for (const candidate of mentionCandidatesWithTeams) {
|
||||
for (const name of [
|
||||
candidate.displayName,
|
||||
candidate.personaName,
|
||||
@@ -495,7 +474,7 @@ export function useMentions(
|
||||
}
|
||||
|
||||
return names;
|
||||
}, [mentionCandidates]);
|
||||
}, [mentionCandidatesWithTeams]);
|
||||
|
||||
const highlightNames = React.useMemo<string[]>(() => {
|
||||
const names: string[] = [];
|
||||
@@ -562,11 +541,14 @@ export function useMentions(
|
||||
}
|
||||
|
||||
return rankMentionCandidates(
|
||||
mentionCandidates,
|
||||
mentionCandidatesWithTeams,
|
||||
mentionQuery,
|
||||
activePersonaIds,
|
||||
)
|
||||
.slice(0, Math.max(MENTION_SUGGESTION_LIMIT, mentionCandidates.length))
|
||||
.slice(
|
||||
0,
|
||||
Math.max(MENTION_SUGGESTION_LIMIT, mentionCandidatesWithTeams.length),
|
||||
)
|
||||
.map(({ candidate, label }) =>
|
||||
mapMentionCandidateToSuggestion({
|
||||
candidate,
|
||||
@@ -580,7 +562,7 @@ export function useMentions(
|
||||
}, [
|
||||
activePersonaIds,
|
||||
currentPubkey,
|
||||
mentionCandidates,
|
||||
mentionCandidatesWithTeams,
|
||||
mentionQuery,
|
||||
options?.channelType,
|
||||
ownerProfilesQuery.data?.profiles,
|
||||
@@ -639,45 +621,49 @@ export function useMentions(
|
||||
}
|
||||
|
||||
const displayName = suggestion.displayName;
|
||||
const insertText = `@${displayName} `;
|
||||
const teamMembers =
|
||||
suggestion.kind === "team" ? suggestion.teamMembers : null;
|
||||
const insertText = teamMembers
|
||||
? formatTeamMention(displayName, teamMembers)
|
||||
: `@${displayName} `;
|
||||
|
||||
const mentions = mentionMapRef.current;
|
||||
const personaMentions = personaMentionMapRef.current;
|
||||
if (suggestion.kind === "persona" && suggestion.personaId) {
|
||||
personaMentions.set(displayName, suggestion.personaId);
|
||||
mentions.delete(displayName);
|
||||
} else if (suggestion.pubkey) {
|
||||
mentions.set(displayName, suggestion.pubkey);
|
||||
personaMentions.delete(displayName);
|
||||
const selectedMentions = teamMembers ?? [suggestion];
|
||||
for (const selected of selectedMentions) {
|
||||
if (selected.kind === "persona" && selected.personaId) {
|
||||
personaMentions.set(selected.displayName, selected.personaId);
|
||||
mentions.delete(selected.displayName);
|
||||
} else if (selected.pubkey) {
|
||||
mentions.set(selected.displayName, selected.pubkey);
|
||||
personaMentions.delete(selected.displayName);
|
||||
}
|
||||
}
|
||||
setSelectedMentionNames((current) => {
|
||||
if (
|
||||
current.some(
|
||||
(name) => name.toLowerCase() === displayName.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return [...current, displayName];
|
||||
const known = new Set(current.map((name) => name.toLowerCase()));
|
||||
return [
|
||||
...current,
|
||||
...selectedMentions
|
||||
.map((selected) => selected.displayName)
|
||||
.filter((name) => !known.has(name.toLowerCase())),
|
||||
];
|
||||
});
|
||||
const isAgentMention =
|
||||
suggestion.kind === "persona" ||
|
||||
suggestion.kind === "team" ||
|
||||
suggestion.isAgent === true ||
|
||||
(suggestion.pubkey
|
||||
? knownAgentPubkeys.has(normalizePubkey(suggestion.pubkey))
|
||||
: false);
|
||||
if (isAgentMention) {
|
||||
setSelectedAgentMentionNames((current) => {
|
||||
if (
|
||||
current.some(
|
||||
(name) => name.toLowerCase() === displayName.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return [...current, displayName];
|
||||
const known = new Set(current.map((name) => name.toLowerCase()));
|
||||
return [
|
||||
...current,
|
||||
...selectedMentions
|
||||
.map((selected) => selected.displayName)
|
||||
.filter((name) => !known.has(name.toLowerCase())),
|
||||
];
|
||||
});
|
||||
}
|
||||
trimMapToSize(mentions, 200);
|
||||
@@ -919,7 +905,7 @@ export function useMentions(
|
||||
latestValueRef,
|
||||
latestCursorRef,
|
||||
searchableNamesLowerRef,
|
||||
candidates: mentionCandidates,
|
||||
candidates: mentionCandidatesWithTeams,
|
||||
activePersonaIds,
|
||||
channelType: options?.channelType,
|
||||
currentPubkey,
|
||||
@@ -953,7 +939,7 @@ export function useMentions(
|
||||
activePersonaIds,
|
||||
currentPubkey,
|
||||
isMentionOpen,
|
||||
mentionCandidates,
|
||||
mentionCandidatesWithTeams,
|
||||
mentionSelectedIndex,
|
||||
options?.channelType,
|
||||
ownerProfilesQuery.data?.profiles,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { Bot, Users } from "lucide-react";
|
||||
import type { TeamMentionMember } from "@/features/messages/lib/mentionCandidates";
|
||||
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
@@ -15,7 +16,9 @@ import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
export type MentionSuggestion = {
|
||||
pubkey?: string;
|
||||
personaId?: string;
|
||||
kind?: "identity" | "persona";
|
||||
teamId?: string;
|
||||
teamMembers?: TeamMentionMember[];
|
||||
kind?: "identity" | "persona" | "team";
|
||||
displayName: string;
|
||||
avatarUrl?: string | null;
|
||||
isAgent?: boolean;
|
||||
@@ -95,6 +98,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
const suggestionKey =
|
||||
suggestion.pubkey ??
|
||||
(suggestion.personaId ? `persona-${suggestion.personaId}` : null) ??
|
||||
(suggestion.teamId ? `team-${suggestion.teamId}` : null) ??
|
||||
suggestion.displayName;
|
||||
const agentLabel = "agent";
|
||||
const hasNameCollision =
|
||||
@@ -121,12 +125,18 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={suggestion.avatarUrl ?? null}
|
||||
displayName={suggestion.displayName}
|
||||
size="xs"
|
||||
testId="mention-suggestion-avatar"
|
||||
/>
|
||||
{suggestion.kind === "team" ? (
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Users aria-hidden="true" className="h-4 w-4" />
|
||||
</span>
|
||||
) : (
|
||||
<UserAvatar
|
||||
avatarUrl={suggestion.avatarUrl ?? null}
|
||||
displayName={suggestion.displayName}
|
||||
size="xs"
|
||||
testId="mention-suggestion-avatar"
|
||||
/>
|
||||
)}
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span
|
||||
className="min-w-0 break-words font-medium leading-snug"
|
||||
@@ -134,7 +144,8 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
>
|
||||
{suggestion.displayName}
|
||||
</span>
|
||||
{suggestion.isAgent ||
|
||||
{suggestion.kind === "team" ||
|
||||
suggestion.isAgent ||
|
||||
suggestion.role ||
|
||||
suggestion.ownerLabel ||
|
||||
suggestion.notInChannel ? (
|
||||
@@ -146,7 +157,12 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{suggestion.isAgent ? (
|
||||
{suggestion.kind === "team" ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1">
|
||||
<Users aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
team · {suggestion.teamMembers?.length ?? 0} agents
|
||||
</span>
|
||||
) : suggestion.isAgent ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1">
|
||||
<Bot
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -97,6 +97,13 @@ type MockPersonaSeed = {
|
||||
envVars?: Record<string, string>;
|
||||
};
|
||||
|
||||
type MockTeamSeed = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
personaIds: string[];
|
||||
};
|
||||
|
||||
type MockSearchProfileSeed = {
|
||||
pubkey: string;
|
||||
displayName: string | null;
|
||||
@@ -123,6 +130,7 @@ type E2eConfig = {
|
||||
};
|
||||
managedAgents?: MockManagedAgentSeed[];
|
||||
personas?: MockPersonaSeed[];
|
||||
teams?: MockTeamSeed[];
|
||||
relayAgents?: MockRelayAgentSeed[];
|
||||
agentListDelayMs?: number;
|
||||
agentMemory?: RawAgentMemoryListing | Record<string, RawAgentMemoryListing>;
|
||||
@@ -1953,7 +1961,7 @@ function resetMockPersonas(config?: E2eConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
function resetMockTeams() {
|
||||
function resetMockTeams(config?: E2eConfig) {
|
||||
const now = new Date().toISOString();
|
||||
mockTeams = [
|
||||
{
|
||||
@@ -1996,6 +2004,22 @@ function resetMockTeams() {
|
||||
updated_at: now,
|
||||
},
|
||||
];
|
||||
|
||||
for (const team of config?.mock?.teams ?? []) {
|
||||
mockTeams.push({
|
||||
id: team.id ?? crypto.randomUUID(),
|
||||
name: team.name,
|
||||
description: team.description ?? null,
|
||||
persona_ids: [...team.personaIds],
|
||||
is_builtin: false,
|
||||
source_dir: null,
|
||||
is_symlink: false,
|
||||
symlink_target: null,
|
||||
version: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function seedMockSearchProfiles(config?: E2eConfig) {
|
||||
@@ -8235,7 +8259,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
resetMockRelayAgents(config);
|
||||
resetMockManagedAgents(config);
|
||||
resetMockPersonas(config);
|
||||
resetMockTeams();
|
||||
resetMockTeams(config);
|
||||
seedMockSearchProfiles(config);
|
||||
resetMockWorkflows();
|
||||
resetMockMesh();
|
||||
|
||||
70
desktop/tests/e2e/team-mentions.spec.ts
Normal file
70
desktop/tests/e2e/team-mentions.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
|
||||
const SHOTS = "test-results/team-mentions";
|
||||
|
||||
test.use({ viewport: { width: 1280, height: 720 } });
|
||||
|
||||
test("owned team mention unfurls into its agents", async ({ page }) => {
|
||||
await installMockBridge(page, {
|
||||
personas: [
|
||||
{
|
||||
id: "persona-planner",
|
||||
displayName: "Planner",
|
||||
systemPrompt: "Plan the work.",
|
||||
},
|
||||
{
|
||||
id: "persona-builder",
|
||||
displayName: "Builder",
|
||||
systemPrompt: "Build the solution.",
|
||||
},
|
||||
{
|
||||
id: "persona-reviewer",
|
||||
displayName: "Reviewer",
|
||||
systemPrompt: "Review the result.",
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
id: "team-launch",
|
||||
name: "Launch Team",
|
||||
description: "Plans, builds, and reviews launches.",
|
||||
personaIds: ["persona-planner", "persona-builder", "persona-reviewer"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
|
||||
const composer = page.getByTestId("message-composer");
|
||||
const input = composer.getByTestId("message-input");
|
||||
await input.fill("Coordinate with @Launch");
|
||||
|
||||
const autocomplete = composer.getByTestId("mention-autocomplete");
|
||||
const teamRow = autocomplete.getByTestId(
|
||||
"mention-suggestion-team-team-launch",
|
||||
);
|
||||
await expect(teamRow).toContainText("Launch Team");
|
||||
await expect(teamRow).toContainText("team · 3 agents");
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/01-owned-team-suggestion.png`,
|
||||
clip: { x: 240, y: 380, width: 800, height: 320 },
|
||||
});
|
||||
|
||||
await input.press("Enter");
|
||||
await expect
|
||||
.poll(() => input.evaluate((element) => element.textContent))
|
||||
.toContain("Coordinate with Launch Team(@Planner @Builder @Reviewer)");
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveCount(3);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/02-unfurled-team-members.png`,
|
||||
clip: { x: 240, y: 480, width: 800, height: 220 },
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,13 @@ type MockPersonaSeed = {
|
||||
envVars?: Record<string, string>;
|
||||
};
|
||||
|
||||
type MockTeamSeed = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
personaIds: string[];
|
||||
};
|
||||
|
||||
export type MockEngramEntry = {
|
||||
slug: string;
|
||||
body: string;
|
||||
@@ -150,6 +157,7 @@ type MockBridgeOptions = {
|
||||
};
|
||||
managedAgents?: MockManagedAgentSeed[];
|
||||
personas?: MockPersonaSeed[];
|
||||
teams?: MockTeamSeed[];
|
||||
relayAgents?: MockRelayAgentSeed[];
|
||||
agentListDelayMs?: number;
|
||||
createManagedAgentDelayMs?: number;
|
||||
|
||||
Reference in New Issue
Block a user