feat(desktop): expand owned team mentions (#1918)

This commit is contained in:
Atish Patel
2026-07-15 17:39:39 -05:00
committed by GitHub
parent 8212a87231
commit 470458d325
16 changed files with 591 additions and 93 deletions

View File

@@ -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",

View File

@@ -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);
});

View File

@@ -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);

View 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,
),
[],
);
});

View 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(" ")}) `;
}

View File

@@ -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);

View File

@@ -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",
),
);
}

View File

@@ -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"],
);
});

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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);
});

View File

@@ -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,

View File

@@ -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"

View File

@@ -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();

View 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 },
});
});

View File

@@ -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;