Skip to content
Merged
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions desktop/src/features/messages/lib/flushMentionDebounce.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
5 changes: 3 additions & 2 deletions desktop/src/features/messages/lib/hasMention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 >=
Expand All @@ -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);
Expand Down
172 changes: 172 additions & 0 deletions desktop/src/features/messages/lib/mentionCandidates.test.mjs
Original file line number Diff line number Diff line change
@@ -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 changes: 132 additions & 0 deletions desktop/src/features/messages/lib/mentionCandidates.ts
Original file line number Diff line number Diff line change
@@ -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(" ")}) `;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading