diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index dff8240ea4..c6d9d1bccf 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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", diff --git a/desktop/src/features/messages/lib/flushMentionDebounce.test.mjs b/desktop/src/features/messages/lib/flushMentionDebounce.test.mjs index ab5a491a2d..4dfd9d9927 100644 --- a/desktop/src/features/messages/lib/flushMentionDebounce.test.mjs +++ b/desktop/src/features/messages/lib/flushMentionDebounce.test.mjs @@ -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); +}); diff --git a/desktop/src/features/messages/lib/hasMention.ts b/desktop/src/features/messages/lib/hasMention.ts index d736f96463..a263614e5d 100644 --- a/desktop/src/features/messages/lib/hasMention.ts +++ b/desktop/src/features/messages/lib/hasMention.ts @@ -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); diff --git a/desktop/src/features/messages/lib/mentionCandidates.test.mjs b/desktop/src/features/messages/lib/mentionCandidates.test.mjs new file mode 100644 index 0000000000..355b56dfea --- /dev/null +++ b/desktop/src/features/messages/lib/mentionCandidates.test.mjs @@ -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, + ), + [], + ); +}); diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts new file mode 100644 index 0000000000..0498bef9ae --- /dev/null +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -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(); + 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(" ")}) `; +} diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs index 0931a7324b..164225fd80 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs @@ -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); diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index 3039515d68..1fad414084 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -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", + ), ); } diff --git a/desktop/src/features/messages/lib/mentionRanking.test.mjs b/desktop/src/features/messages/lib/mentionRanking.test.mjs index 893972237e..2e74bba52d 100644 --- a/desktop/src/features/messages/lib/mentionRanking.test.mjs +++ b/desktop/src/features/messages/lib/mentionRanking.test.mjs @@ -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"], + ); +}); diff --git a/desktop/src/features/messages/lib/mentionRanking.ts b/desktop/src/features/messages/lib/mentionRanking.ts index 590b8f4018..09b9e03de7 100644 --- a/desktop/src/features/messages/lib/mentionRanking.ts +++ b/desktop/src/features/messages/lib/mentionRanking.ts @@ -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; diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts index 436391a7a4..c710cf613b 100644 --- a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts @@ -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, }; diff --git a/desktop/src/features/messages/lib/useMentions.test.mjs b/desktop/src/features/messages/lib/useMentions.test.mjs index e1a6eca908..634e4f99d1 100644 --- a/desktop/src/features/messages/lib/useMentions.test.mjs +++ b/desktop/src/features/messages/lib/useMentions.test.mjs @@ -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); }); diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 1034e533b7..7a750f473d 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -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(); - 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(() => { 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, diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 1dfa6cf9c6..8f54606f4e 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -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" > - + {suggestion.kind === "team" ? ( + + + ) : ( + + )} {suggestion.displayName} - {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" ? ( + + + ) : suggestion.isAgent ? (