Add Default Agent Support and promote Agents to be part of suite (#35091)
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions

* Add default agent support and App Bar integration

- Add Agents section to App Bar, separating it from Core Products.
- Implement Default Agent logic in AtMentionProvider:
  - Promote default agent to top of suggestions for empty '@' prefix.
  - Filter duplicate agent entry from main list when default is shown.
- Add `AgentTag` component for UI distinction.
- Update `mattermost-plugin-ai` and `server/public` dependencies.
- Add unit tests for default agent suggestion logic.

* Add missing files (server deps, types)

* Fix pre-commit check failures

- Fix TypeScript errors in test files:
  - Add missing displayName property to defaultAgent in at_mention_provider test
  - Add missing fetchAgents mock in textbox test
- Fix Go assignment mismatch in integration_action_test.go (CreatePostAsUser returns 3 values)
- Fix license copyright year in plugins/mattermost-ai/assets/embed.go
- Update i18n translations (add tag.default.agent)
- Regenerate Go serialized files, mmctl docs, and update go.mod/go.sum

* Update snapshot tests for textbox and at_mention_suggestion

* Undo mmctl docs changes

* Undo more changes

* revert package-lock.json

* Update dep for ai plugin

* Update again

* Update at_mention_provider to filter out agent duplicates and add .cursor/ to gitignore

- Filter agent usernames from priorityProfiles and localAndRemoteMembers to prevent duplicate entries in autocomplete suggestions
- Add .cursor/ directory to .gitignore

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nick Misasi 2026-02-11 21:59:20 -05:00 committed by GitHub
parent e4bd8398ab
commit 4269ebf913
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 346 additions and 37 deletions

1
.gitignore vendored
View file

@ -163,3 +163,4 @@ docker-compose.override.yaml
**/CLAUDE.local.md
**/CLAUDE.md
.cursorrules
.cursor/

View file

@ -43,8 +43,8 @@ require (
github.com/mattermost/gosaml2 v0.10.0
github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956
github.com/mattermost/logr/v2 v2.0.22
github.com/mattermost/mattermost-plugin-ai v1.5.0
github.com/mattermost/mattermost/server/public v0.1.20
github.com/mattermost/mattermost-plugin-ai v1.8.1
github.com/mattermost/mattermost/server/public v0.1.22-0.20251105210629-8bf4a00724e2
github.com/mattermost/morph v1.1.0
github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0
github.com/mattermost/squirrel v0.5.0
@ -131,7 +131,7 @@ require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/gomodule/redigo v2.0.0+incompatible // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/jsonschema-go v0.2.3 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect

View file

@ -267,8 +267,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM=
github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
@ -406,10 +406,14 @@ github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb
github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI=
github.com/mattermost/logr/v2 v2.0.22 h1:npFkXlkAWR9J8payh8ftPcCZvLbHSI125mAM5/r/lP4=
github.com/mattermost/logr/v2 v2.0.22/go.mod h1:0sUKpO+XNMZApeumaid7PYaUZPBIydfuWZ0dqixXo+s=
github.com/mattermost/mattermost-plugin-ai v1.5.0 h1:64P8CadbrglgiQMiYqE9kZngrvIb5Ze7Jv+iK832RbI=
github.com/mattermost/mattermost-plugin-ai v1.5.0/go.mod h1:sgR9+nLFCjYSE9vlqxLZxHZ+6Kz2NJw9Qko+ywVX2k0=
github.com/mattermost/mattermost/server/public v0.1.20 h1:N39ZOyYvCVZXABetpb0tVrm/08Od3q7d/D45QZjJ63s=
github.com/mattermost/mattermost/server/public v0.1.20/go.mod h1:ZSdLCHIFYDTmX5FNCJ7LoI2CLZ1rQqZcfJ3ZF81QadY=
github.com/mattermost/mattermost-plugin-ai v1.7.3-0.20260128145904-ae362cf86c17 h1:Mz9ZbcFPF3OItbDtor/FGJRHNfi7QFnPESjgCmL876A=
github.com/mattermost/mattermost-plugin-ai v1.7.3-0.20260128145904-ae362cf86c17/go.mod h1:giiKPJpaLV4qv/odeGMyO7ltkgaWZzsbUA92Wi7EMkA=
github.com/mattermost/mattermost-plugin-ai v1.8.1-0.20260205142725-644bf6ed9297 h1:oojqLsPxMAY7tMOG6cQZ0BAfiYZvGMarRi3dq7krzpU=
github.com/mattermost/mattermost-plugin-ai v1.8.1-0.20260205142725-644bf6ed9297/go.mod h1:Uco4K7ypsrZWcD256ezvgZDqolJfHYiExN2lYiHmVDo=
github.com/mattermost/mattermost-plugin-ai v1.8.1 h1:qymxDayy3vJPhm59XA8q0oLR8uobPgx0SOB7IMG9ZMM=
github.com/mattermost/mattermost-plugin-ai v1.8.1/go.mod h1:Uco4K7ypsrZWcD256ezvgZDqolJfHYiExN2lYiHmVDo=
github.com/mattermost/mattermost/server/public v0.1.22-0.20251105210629-8bf4a00724e2 h1:RJtCnj9nF/wb0Fb+O0qAPgUoWP5CTTDnHzHD5ciGlJ8=
github.com/mattermost/mattermost/server/public v0.1.22-0.20251105210629-8bf4a00724e2/go.mod h1:X0RG3lk0XK0SFSH67JS/xporlz3TxItHEPlFIrsQIa8=
github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw=
github.com/mattermost/morph v1.1.0/go.mod h1:gD+EaqX2UMyyuzmF4PFh4r33XneQ8Nzi+0E8nXjMa3A=
github.com/mattermost/msgpack/v5 v5.0.0-20260120151306-2f9c67d7e57f h1:tAXeRJSWo6EK7wDq1TcxMHIxHRyjrE62ihvsigdg4Q0=
@ -542,13 +546,8 @@ github.com/reflog/dateconstraints v0.2.1 h1:Hz1n2Q1vEm0Rj5gciDQcCN1iPBwfFjxUJy32
github.com/reflog/dateconstraints v0.2.1/go.mod h1:Ax8AxTBcJc3E/oVS2hd2j7RDM/5MDtuPwuR7lIHtPLo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.5 h1:kNlmACZuwC8ZWPLoJtD+HtZOsKJgYn7gXgUIcRB7dbo=
github.com/richardlehane/msoleps v1.0.5/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=

View file

@ -40,7 +40,6 @@ require (
github.com/fatih/color v1.18.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
@ -59,7 +58,6 @@ require (
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wiggin77/merror v1.0.5 // indirect
github.com/wiggin77/srslog v1.0.1 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect

View file

@ -167,6 +167,7 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russellhaering/goxmldsig v1.2.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
github.com/russellhaering/goxmldsig v1.5.0 h1:AU2UkkYIUOTyZRbe08XMThaOCelArgvNfYapcmSjBNw=
github.com/russellhaering/goxmldsig v1.5.0/go.mod h1:x98CjQNFJcWfMxeOrMnMKg70lvDP6tE0nTaeUnjXDmk=

View file

@ -100,6 +100,7 @@ exports[`components/TextBox should match snapshot with additional, optional prop
"channelId": "channelId",
"currentUserId": "currentUserId",
"data": null,
"defaultAgent": undefined,
"disableDispatches": false,
"forceDispatch": false,
"getProfilesInChannel": [Function],
@ -242,6 +243,7 @@ exports[`components/TextBox should match snapshot with required props 1`] = `
"channelId": "channelId",
"currentUserId": "currentUserId",
"data": null,
"defaultAgent": undefined,
"disableDispatches": false,
"forceDispatch": false,
"getProfilesInChannel": [Function],
@ -384,6 +386,7 @@ exports[`components/TextBox should throw error when new property is too long 1`]
"channelId": "channelId",
"currentUserId": "currentUserId",
"data": null,
"defaultAgent": undefined,
"disableDispatches": false,
"forceDispatch": false,
"getProfilesInChannel": [Function],
@ -526,6 +529,7 @@ exports[`components/TextBox should throw error when value is too long 1`] = `
"channelId": "channelId",
"currentUserId": "currentUserId",
"data": null,
"defaultAgent": undefined,
"disableDispatches": false,
"forceDispatch": false,
"getProfilesInChannel": [Function],

View file

@ -43,16 +43,27 @@ export default function AppBar() {
}
const coreProductsPluginIds = [suitePluginIds.focalboard, suitePluginIds.playbooks];
const agentsPluginId = suitePluginIds.agents;
// Partition app bar components: Playbooks/Boards vs other plugins
const [coreProductComponents, pluginComponents] = partition(appBarPluginComponents, ({pluginId}) => {
return coreProductsPluginIds.includes(pluginId);
});
// Partition channel header components: Agents vs others
const [agentsComponents, otherChannelHeaderComponents] = partition(channelHeaderComponents, ({pluginId}) => {
return pluginId === agentsPluginId;
});
const items = [
...agentsComponents,
...coreProductComponents,
getDivider(coreProductComponents.length, (pluginComponents.length + channelHeaderComponents.length + appBarBindings.length)),
getDivider(
agentsComponents.length + coreProductComponents.length,
pluginComponents.length + otherChannelHeaderComponents.length + appBarBindings.length,
),
...pluginComponents,
...channelHeaderComponents,
...otherChannelHeaderComponents,
...appBarBindings,
].map((x) => {
if (!x) {

View file

@ -21,6 +21,7 @@ exports[`at mention suggestion should display nick name of non signed in user 1`
<SuggestionContainer
aria-describedby="test-suggestion-1-description test-suggestion-1-youElement test-suggestion-1-status test-suggestion-1-botTag test-suggestion-1-sharedIcon test-suggestion-1-guestTag test-suggestion-1-groupMembers"
aria-labelledby="test-suggestion-1-atMention"
className=""
data-testid="mentionSuggestion_user2"
id="test-suggestion-1"
isSelection={false}
@ -130,6 +131,7 @@ exports[`at mention suggestion should not display nick name of the signed in use
<SuggestionContainer
aria-describedby="test-suggestion-1-description test-suggestion-1-youElement test-suggestion-1-status test-suggestion-1-botTag test-suggestion-1-sharedIcon test-suggestion-1-guestTag test-suggestion-1-groupMembers"
aria-labelledby="test-suggestion-1-atMention"
className=""
data-testid="mentionSuggestion_user"
id="test-suggestion-1"
isSelection={false}

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import AtMentionProvider, {groupsGroup, membersGroup, nonMembersGroup, otherMembersGroup, specialMentionsGroup, type Props} from 'components/suggestion/at_mention_provider/at_mention_provider';
import AtMentionProvider, {groupsGroup, membersGroup, nonMembersGroup, otherMembersGroup, specialMentionsGroup, defaultAgentGroup, type Props} from 'components/suggestion/at_mention_provider/at_mention_provider';
import {TestHelper} from 'utils/test_helper';
@ -30,6 +30,7 @@ describe('components/suggestion/at_mention_provider/AtMentionProvider', () => {
useChannelMentions: true,
searchAssociatedGroupsForReference: jest.fn().mockResolvedValue(false),
priorityProfiles: [],
defaultAgent: undefined,
};
it('should ignore pretexts that are not at-mentions', () => {
@ -145,6 +146,164 @@ describe('components/suggestion/at_mention_provider/AtMentionProvider', () => {
});
});
it('should suggest default agent for "@" when configured', async () => {
const pretext = '@';
const matchedPretext = '@';
const agentUser = TestHelper.getUserMock({id: 'agentId', username: 'ai-agent', first_name: 'AI', last_name: 'Agent', is_bot: true});
const defaultAgent = {
id: 'agentId',
displayName: 'AI Agent',
username: 'ai-agent',
service_id: 'serviceId',
service_type: 'type',
is_default: true,
};
const itemsCall = [
defaultAgentGroup({...agentUser, isAgent: true, isDefaultAgent: true}),
membersGroup([
userid10,
userid3,
userid1,
userid2,
userid4,
]),
groupsGroup([
groupid1,
groupid2,
groupid3,
]),
specialMentionsGroup([
{username: 'here'},
{username: 'channel'},
{username: 'all'},
]),
nonMembersGroup([
userid5,
userid6,
]),
];
const params = {
...baseParams,
defaultAgent,
autocompleteUsersInChannel: jest.fn().mockImplementation(() => new Promise((resolve) => {
resolve({data: {
users: [userid4],
out_of_channel: [userid5, userid6],
agents: [agentUser],
}});
})),
searchAssociatedGroupsForReference: jest.fn().mockImplementation(() => new Promise((resolve) => {
resolve({
data: [groupid1, groupid2, groupid3],
});
provider.updateMatches(resultCallback, itemsCall, matchedPretext);
})),
};
const provider = new AtMentionProvider(params);
jest.spyOn(provider, 'getProfilesWithLastViewAtInChannel').mockImplementation(() => [userid10, userid3, userid1, userid2]);
const resultCallback = jest.fn();
expect(provider.handlePretextChanged(pretext, resultCallback)).toEqual(true);
await Promise.resolve().then(() => {
expect(resultCallback).toHaveBeenLastCalledWith({
matchedPretext,
groups: itemsCall,
});
});
});
it('should keep pinned default agent out of members and agents when agent is also a member or priority profile', async () => {
const pretext = '@';
const defaultAgentUser = TestHelper.getUserMock({id: 'defaultAgentId', username: 'default-agent', first_name: 'Default', last_name: 'Agent', is_bot: true});
const otherAgentUser = TestHelper.getUserMock({id: 'otherAgentId', username: 'other-agent', first_name: 'Other', last_name: 'Agent', is_bot: true});
const defaultAgent = {
id: 'defaultAgentId',
displayName: 'Default Agent',
username: 'default-agent',
service_id: 'serviceId',
service_type: 'type',
is_default: true,
};
const params = {
...baseParams,
defaultAgent,
priorityProfiles: [defaultAgentUser],
autocompleteUsersInChannel: jest.fn().mockResolvedValue({data: {
users: [defaultAgentUser, userid4],
out_of_channel: [userid5, userid6],
agents: [defaultAgentUser, otherAgentUser],
}}),
searchAssociatedGroupsForReference: jest.fn().mockResolvedValue({
data: [groupid1, groupid2, groupid3],
}),
};
const provider = new AtMentionProvider(params);
jest.spyOn(provider, 'getProfilesWithLastViewAtInChannel').mockImplementation(() => [defaultAgentUser, userid10, userid3, userid1, userid2]);
const resultCallback = jest.fn();
expect(provider.handlePretextChanged(pretext, resultCallback)).toEqual(true);
await Promise.resolve();
await Promise.resolve();
const lastResult = resultCallback.mock.lastCall?.[0];
expect(lastResult).toBeDefined();
const groups = lastResult.groups;
expect(groups[0].key).toBe('defaultAgent');
const pinnedDefaultAgent = groups.find((group: {key: string}) => group.key === 'defaultAgent');
const members = groups.find((group: {key: string}) => group.key === 'members');
const agents = groups.find((group: {key: string}) => group.key === 'agents');
expect(pinnedDefaultAgent?.items[0].username).toBe(defaultAgentUser.username);
expect(members?.items.map((item: {username: string}) => item.username)).not.toContain(defaultAgentUser.username);
expect(agents?.items.map((item: {username: string}) => item.username)).not.toContain(defaultAgentUser.username);
});
it('should keep non-default agents out of channel members when they are also in channel', async () => {
const pretext = '@';
const agentUser = TestHelper.getUserMock({id: 'agentId', username: 'member-agent', first_name: 'Member', last_name: 'Agent', is_bot: true});
const params = {
...baseParams,
autocompleteUsersInChannel: jest.fn().mockResolvedValue({data: {
users: [agentUser, userid4],
out_of_channel: [userid5, userid6],
agents: [agentUser],
}}),
searchAssociatedGroupsForReference: jest.fn().mockResolvedValue({
data: [groupid1, groupid2, groupid3],
}),
};
const provider = new AtMentionProvider(params);
jest.spyOn(provider, 'getProfilesWithLastViewAtInChannel').mockImplementation(() => [agentUser, userid10, userid3, userid1, userid2]);
const resultCallback = jest.fn();
expect(provider.handlePretextChanged(pretext, resultCallback)).toEqual(true);
await Promise.resolve();
await Promise.resolve();
const lastResult = resultCallback.mock.lastCall?.[0];
expect(lastResult).toBeDefined();
const groups = lastResult.groups;
const members = groups.find((group: {key: string}) => group.key === 'members');
const agents = groups.find((group: {key: string}) => group.key === 'agents');
expect(agents?.items.map((item: {username: string}) => item.username)).toContain(agentUser.username);
expect(members?.items.map((item: {username: string}) => item.username)).not.toContain(agentUser.username);
});
it('should have priorityProfiles at the top', async () => {
const userid11 = TestHelper.getUserMock({id: 'userid11', username: 'user11', first_name: 'firstname11', last_name: 'lastname11', nickname: 'nickname11'});
const userid12 = TestHelper.getUserMock({id: 'userid12', username: 'user12', first_name: 'firstname12', last_name: 'lastname12', nickname: 'nickname12'});

View file

@ -5,6 +5,7 @@ import React from 'react';
import {defineMessage} from 'react-intl';
import {CreationOutlineIcon} from '@mattermost/compass-icons/components';
import type {Agent} from '@mattermost/types/agents';
import type {Group} from '@mattermost/types/groups';
import type {UserProfile} from '@mattermost/types/users';
@ -34,6 +35,8 @@ type UserProfileWithLastViewAt = UserProfile & {last_viewed_at?: number};
type CreatedProfile = UserProfile & {
isCurrentUser?: boolean;
last_viewed_at?: number;
isAgent?: boolean;
isDefaultAgent?: boolean;
};
type SpecialMention = {
@ -46,10 +49,19 @@ export type Props = {
autocompleteUsersInChannel: (prefix: string) => Promise<ActionResult>;
useChannelMentions: boolean;
autocompleteGroups: Group[] | null;
searchAssociatedGroupsForReference: (prefix: string) => Promise<{data: any}>;
searchAssociatedGroupsForReference: (prefix: string) => Promise<{data: Group[]}>;
priorityProfiles: UserProfile[] | undefined;
defaultAgent?: Agent;
}
// Data structure returned by autocomplete API
type AutocompleteData = {
users?: UserProfileWithLastViewAt[];
groups?: Group[];
out_of_channel?: UserProfileWithLastViewAt[];
agents?: UserProfileWithLastViewAt[];
};
// The AtMentionProvider provides matches for at mentions, including @here, @channel, @all,
// users in the channel and users not in the channel. It mixes together results from the local
// store with results fetched from the server.
@ -59,10 +71,11 @@ export default class AtMentionProvider extends Provider {
public autocompleteUsersInChannel: (prefix: string) => Promise<ActionResult>;
public useChannelMentions: boolean;
public autocompleteGroups: Group[] | null;
public searchAssociatedGroupsForReference: (prefix: string) => Promise<{data: any}>;
public searchAssociatedGroupsForReference: (prefix: string) => Promise<{data: Group[]}>;
public priorityProfiles: UserProfile[] | undefined;
public defaultAgent?: Agent;
public data: any;
public data: AutocompleteData | null;
public lastCompletedWord: string;
public lastPrefixWithNoResults: string;
public triggerCharacter: string = '@';
@ -72,7 +85,7 @@ export default class AtMentionProvider extends Provider {
constructor(props: Props) {
super();
const {currentUserId, channelId, autocompleteUsersInChannel, useChannelMentions, autocompleteGroups, searchAssociatedGroupsForReference, priorityProfiles} = props;
const {currentUserId, channelId, autocompleteUsersInChannel, useChannelMentions, autocompleteGroups, searchAssociatedGroupsForReference, priorityProfiles, defaultAgent} = props;
this.currentUserId = currentUserId;
this.channelId = channelId;
@ -81,6 +94,7 @@ export default class AtMentionProvider extends Provider {
this.autocompleteGroups = autocompleteGroups;
this.searchAssociatedGroupsForReference = searchAssociatedGroupsForReference;
this.priorityProfiles = priorityProfiles;
this.defaultAgent = defaultAgent;
this.data = null;
this.lastCompletedWord = '';
@ -90,7 +104,7 @@ export default class AtMentionProvider extends Provider {
this.addLastViewAtToProfiles = makeAddLastViewAtToProfiles();
}
setProps({currentUserId, channelId, autocompleteUsersInChannel, useChannelMentions, autocompleteGroups, searchAssociatedGroupsForReference, priorityProfiles}: Props) {
setProps({currentUserId, channelId, autocompleteUsersInChannel, useChannelMentions, autocompleteGroups, searchAssociatedGroupsForReference, priorityProfiles, defaultAgent}: Props) {
this.currentUserId = currentUserId;
this.channelId = channelId;
this.autocompleteUsersInChannel = autocompleteUsersInChannel;
@ -98,6 +112,7 @@ export default class AtMentionProvider extends Provider {
this.autocompleteGroups = autocompleteGroups;
this.searchAssociatedGroupsForReference = searchAssociatedGroupsForReference;
this.priorityProfiles = priorityProfiles;
this.defaultAgent = defaultAgent;
}
// specialMentions matches one of @here, @channel or @all, unless using /msg.
@ -315,10 +330,18 @@ export default class AtMentionProvider extends Provider {
const agentUsers = this.data.agents as UserProfileWithLastViewAt[];
agents = agentUsers.
filter((user: UserProfileWithLastViewAt) => this.filterProfile(user)).
map((user: UserProfileWithLastViewAt) => this.createFromProfile(user)).
map((user: UserProfileWithLastViewAt) => ({...this.createFromProfile(user), isAgent: true})).
sort(orderUsers);
}
const agentUsernames = new Set(agents.map((agent) => agent.username));
let filteredPriorityProfiles = priorityProfiles;
let filteredLocalAndRemoteMembers = localAndRemoteMembers;
if (this.data?.agents && agentUsernames.size > 0) {
filteredPriorityProfiles = priorityProfiles.filter((member) => !agentUsernames.has(member.username));
filteredLocalAndRemoteMembers = localAndRemoteMembers.filter((member) => !agentUsernames.has(member.username));
}
// handle groups
const localGroups = this.localGroups();
@ -355,11 +378,26 @@ export default class AtMentionProvider extends Provider {
const items = [];
if (priorityProfiles.length > 0 || localAndRemoteMembers.length > 0) {
items.push(membersGroup([...priorityProfiles, ...localAndRemoteMembers]));
const shouldShowDefaultAgentAtTop = this.latestPrefix === '';
if (shouldShowDefaultAgentAtTop && this.defaultAgent) {
const defaultAgentUser = this.findAgentUser(this.defaultAgent);
if (defaultAgentUser) {
items.push(defaultAgentGroup({...this.createFromProfile(defaultAgentUser), isAgent: true, isDefaultAgent: true}));
}
}
if (agents.length > 0) {
items.push(agentsGroup(agents));
if (filteredPriorityProfiles.length > 0 || filteredLocalAndRemoteMembers.length > 0) {
items.push(membersGroup([...filteredPriorityProfiles, ...filteredLocalAndRemoteMembers]));
}
// Filter out default agent from agents group when shown at top to avoid duplicate entries
let agentsToShow = agents;
if (shouldShowDefaultAgentAtTop && this.defaultAgent) {
agentsToShow = agents.filter((agent) => agent.username !== this.defaultAgent?.username);
}
if (agentsToShow.length > 0) {
items.push(agentsGroup(agentsToShow));
}
if (localAndRemoteGroups.length > 0) {
items.push(groupsGroup(localAndRemoteGroups));
@ -457,6 +495,15 @@ export default class AtMentionProvider extends Provider {
return profile;
}
findAgentUser(agent: Agent): UserProfileWithLastViewAt | undefined {
if (!this.data?.agents || !Array.isArray(this.data.agents)) {
return undefined;
}
return (this.data.agents as UserProfileWithLastViewAt[]).find(
(user) => user.username === agent.username,
);
}
}
export function membersGroup(items: CreatedProfile[]) {
@ -469,6 +516,15 @@ export function membersGroup(items: CreatedProfile[]) {
};
}
export function defaultAgentGroup(item: CreatedProfile) {
return {
key: 'defaultAgent',
items: [item],
terms: ['@' + item.username],
component: AtMentionSuggestion,
};
}
export function agentsGroup(items: CreatedProfile[]) {
return {
key: 'agents',

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import type {ReactNode} from 'react';
import {FormattedMessage} from 'react-intl';
@ -13,6 +14,7 @@ import usePrefixedIds, {joinIds} from 'components/common/hooks/usePrefixedIds';
import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
import SharedUserIndicator from 'components/shared_user_indicator';
import StatusIcon from 'components/status_icon';
import AgentTag from 'components/widgets/tag/agent_tag';
import BotTag from 'components/widgets/tag/bot_tag';
import GuestTag from 'components/widgets/tag/guest_tag';
import Tag from 'components/widgets/tag/tag';
@ -25,6 +27,8 @@ import type {SuggestionProps} from '../suggestion';
export interface Item extends UserProfile {
isCurrentUser: boolean;
isAgent?: boolean;
isDefaultAgent?: boolean;
}
interface Group extends Item {
@ -199,6 +203,7 @@ const AtMentionSuggestion = React.forwardRef<HTMLLIElement, SuggestionProps<Item
<SuggestionContainer
ref={ref}
{...props}
className={classNames({'suggestion-list__item--default-agent': item.isDefaultAgent})}
aria-labelledby={ids.atMention}
aria-describedby={joinIds(ids.description, ids.youElement, ids.status, ids.botTag, ids.sharedIcon, ids.guestTag, ids.groupMembers)}
data-testid={`mentionSuggestion_${itemname}`}
@ -211,7 +216,7 @@ const AtMentionSuggestion = React.forwardRef<HTMLLIElement, SuggestionProps<Item
>
{'@' + itemname}
</span>
{item.is_bot && <span id={ids.botTag}><BotTag/></span>}
{item.is_bot && <span id={ids.botTag}>{item.isAgent ? <AgentTag/> : <BotTag/>}</span>}
{description && <span id={ids.description}>{description}</span>}
{youElement}
{customStatus}

View file

@ -30,6 +30,7 @@ const SuggestionContainer = React.forwardRef<HTMLLIElement, SuggestionProps<unkn
onMouseMove,
tabIndex = -1,
className,
...otherProps
} = props;
@ -50,7 +51,7 @@ const SuggestionContainer = React.forwardRef<HTMLLIElement, SuggestionProps<unkn
return (
<li
ref={ref}
className={classNames('suggestion-list__item', {'suggestion--selected': isSelection})}
className={classNames('suggestion-list__item', {'suggestion--selected': isSelection}, className)}
role='option'
onClick={handleClick}
onMouseMove={handleMouseMove}

View file

@ -212,10 +212,21 @@ function GroupedSuggestionsGroup({
}: {
children: React.ReactNode;
groupKey: string | undefined;
labelMessage: MessageDescriptor | string;
labelMessage?: MessageDescriptor | string;
}) {
const labelId = `suggestionListGroup-${groupKey}`;
// If no label is provided, render the group without a header
if (!labelMessage) {
return (
<ul
role='group'
>
{children}
</ul>
);
}
return (
<ul
role='group'

View file

@ -26,9 +26,10 @@ export type SuggestionResultsGroup<Item = unknown> = {
key: string;
/**
* The label for the group displayed to the user
* The label for the group displayed to the user.
* If omitted, the group will be rendered without a header.
*/
label: MessageDescriptor;
label?: MessageDescriptor;
/**
* A list of strings which the previously typed text may be replaced by.
@ -197,7 +198,7 @@ export type ProviderResultsGrouped<Item = unknown> = {
export type ProviderResultsGroup<Item = unknown> = {
key: string;
label: MessageDescriptor;
label?: MessageDescriptor;
terms: string[];
items: Array<Item | Loading>;

View file

@ -27,6 +27,7 @@ describe('components/TextBox', () => {
autocompleteUsersInChannel: jest.fn(),
autocompleteChannels: jest.fn(),
searchAssociatedGroupsForReference: jest.fn(),
fetchAgents: jest.fn(),
},
useChannelMentions: true,
tabIndex: 0,

View file

@ -7,7 +7,9 @@ import type {Dispatch} from 'redux';
import type {GlobalState} from '@mattermost/types/store';
import {getAgents} from 'mattermost-redux/actions/agents';
import Permissions from 'mattermost-redux/constants/permissions';
import {getDefaultAgent} from 'mattermost-redux/selectors/entities/agents';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {getAssociatedGroupsForReference} from 'mattermost-redux/selectors/entities/groups';
import {makeGetProfilesForThread} from 'mattermost-redux/selectors/entities/posts';
@ -47,6 +49,7 @@ const makeMapStateToProps = () => {
autocompleteGroups,
priorityProfiles: getProfilesForThread(state, ownProps.rootId ?? ''),
delayChannelAutocomplete: getConfig(state).DelayChannelAutocomplete === 'true',
defaultAgent: getDefaultAgent(state),
};
};
};
@ -56,6 +59,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
autocompleteUsersInChannel,
autocompleteChannels,
searchAssociatedGroupsForReference,
fetchAgents: getAgents,
}, dispatch),
});

View file

@ -6,6 +6,7 @@ import React from 'react';
import type {ChangeEvent, ElementType, FocusEvent, KeyboardEvent, MouseEvent} from 'react';
import {FormattedMessage} from 'react-intl';
import type {Agent} from '@mattermost/types/agents';
import type {Channel} from '@mattermost/types/channels';
import type {Group} from '@mattermost/types/groups';
import type {UserProfile} from '@mattermost/types/users';
@ -64,11 +65,13 @@ export type Props = {
autocompleteUsersInChannel: (prefix: string, channelId: string) => Promise<ActionResult>;
autocompleteChannels: (term: string, success: (channels: Channel[]) => void, error: () => void) => Promise<ActionResult>;
searchAssociatedGroupsForReference: (prefix: string, teamId: string, channelId: string | undefined) => Promise<{ data: any }>;
fetchAgents: () => Promise<ActionResult>;
};
useChannelMentions: boolean;
inputComponent?: ElementType;
openWhenEmpty?: boolean;
priorityProfiles?: UserProfile[];
defaultAgent?: Agent;
hasLabels?: boolean;
hasError?: boolean;
};
@ -110,6 +113,7 @@ export default class Textbox extends React.PureComponent<Props> {
autocompleteGroups: this.props.autocompleteGroups,
searchAssociatedGroupsForReference: (prefix: string) => this.props.actions.searchAssociatedGroupsForReference(prefix, this.props.currentTeamId, this.props.channelId),
priorityProfiles: this.props.priorityProfiles,
defaultAgent: this.props.defaultAgent,
}),
new ChannelMentionProvider(props.actions.autocompleteChannels, props.delayChannelAutocomplete),
new EmoticonProvider(),
@ -129,6 +133,12 @@ export default class Textbox extends React.PureComponent<Props> {
this.preview = React.createRef();
}
componentDidMount() {
// Fetch agents once when the component mounts to populate Redux store
// This ensures defaultAgent is available for @ mention suggestions
this.props.actions.fetchAgents();
}
handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.props.onChange(e);
};
@ -139,7 +149,8 @@ export default class Textbox extends React.PureComponent<Props> {
this.props.autocompleteGroups !== prevProps.autocompleteGroups ||
this.props.useChannelMentions !== prevProps.useChannelMentions ||
this.props.currentTeamId !== prevProps.currentTeamId ||
this.props.priorityProfiles !== prevProps.priorityProfiles) {
this.props.priorityProfiles !== prevProps.priorityProfiles ||
this.props.defaultAgent !== prevProps.defaultAgent) {
// Update channel id for AtMentionProvider.
for (const provider of this.suggestionProviders) {
if (provider instanceof AtMentionProvider) {
@ -151,6 +162,7 @@ export default class Textbox extends React.PureComponent<Props> {
autocompleteGroups: this.props.autocompleteGroups,
searchAssociatedGroupsForReference: (prefix: string) => this.props.actions.searchAssociatedGroupsForReference(prefix, this.props.currentTeamId, this.props.channelId),
priorityProfiles: this.props.priorityProfiles,
defaultAgent: this.props.defaultAgent,
});
}
}

View file

@ -0,0 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {useIntl} from 'react-intl';
import Tag from './tag';
import type {TagSize} from './tag';
type Props = {
className?: string;
size?: TagSize;
}
const AgentTag = ({className = '', size = 'xs'}: Props) => {
const {formatMessage} = useIntl();
return (
<Tag
uppercase={true}
size={size}
className={classNames('AgentTag', className)}
text={formatMessage({
id: 'tag.default.agent',
defaultMessage: 'AGENT',
})}
/>
);
};
export default AgentTag;

View file

@ -6244,6 +6244,7 @@
"system_policy_indicator.more_policies": "{count} more",
"system_policy_indicator.multiple_policies_title": "Multiple system access policies applied to this {resourceType}",
"system_policy_indicator.single_policy_title": "System access policy applied to this {resourceType}",
"tag.default.agent": "AGENT",
"tag.default.beta": "BETA",
"tag.default.bot": "BOT",
"tag.default.guest": "GUEST",

View file

@ -5,7 +5,7 @@ import type {Agent, LLMService} from '@mattermost/types/agents';
import type {GlobalState} from '@mattermost/types/store';
export function getAgents(state: GlobalState): Agent[] {
return state.entities.agents?.agents;
return state.entities.agents?.agents || [];
}
export function getAgentsStatus(state: GlobalState): {available: boolean; reason?: string} {
@ -17,6 +17,11 @@ export function getAgent(state: GlobalState, agentId: string): Agent | undefined
return agents.find((agent) => agent.id === agentId);
}
export function getDefaultAgent(state: GlobalState): Agent | undefined {
const agents = getAgents(state);
return agents?.find((agent) => agent.is_default === true);
}
export function getLLMServices(state: GlobalState): LLMService[] {
return state.entities.agents?.llmServices || [];
}

View file

@ -384,3 +384,7 @@
}
}
}
.suggestion-list__item--default-agent {
margin-top: 1.2rem;
}

View file

@ -175,6 +175,7 @@ export const TrialPeriodDays = {
};
export const suitePluginIds = {
agents: 'mattermost-ai',
playbooks: 'playbooks',
focalboard: 'focalboard',

View file

@ -7,6 +7,7 @@ export type Agent = {
username: string;
service_id: string;
service_type: string;
is_default?: boolean;
};
export type LLMService = {