diff --git a/.gitignore b/.gitignore index b4e6be36708..3f2a0e8e900 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,4 @@ docker-compose.override.yaml **/CLAUDE.local.md **/CLAUDE.md .cursorrules +.cursor/ diff --git a/server/go.mod b/server/go.mod index 063308457b2..db29fca0fba 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 diff --git a/server/go.sum b/server/go.sum index c81b48f91ef..dcbc16b7ca4 100644 --- a/server/go.sum +++ b/server/go.sum @@ -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= diff --git a/server/public/go.mod b/server/public/go.mod index 90ab5e9ba95..9dea6eb99cb 100644 --- a/server/public/go.mod +++ b/server/public/go.mod @@ -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 diff --git a/server/public/go.sum b/server/public/go.sum index e33ff93414b..f044752bd5a 100644 --- a/server/public/go.sum +++ b/server/public/go.sum @@ -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= diff --git a/webapp/channels/src/components/__snapshots__/textbox.test.tsx.snap b/webapp/channels/src/components/__snapshots__/textbox.test.tsx.snap index a38633a2f80..e2795627527 100644 --- a/webapp/channels/src/components/__snapshots__/textbox.test.tsx.snap +++ b/webapp/channels/src/components/__snapshots__/textbox.test.tsx.snap @@ -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], diff --git a/webapp/channels/src/components/app_bar/app_bar.tsx b/webapp/channels/src/components/app_bar/app_bar.tsx index ea7a183206f..ff28635171f 100644 --- a/webapp/channels/src/components/app_bar/app_bar.tsx +++ b/webapp/channels/src/components/app_bar/app_bar.tsx @@ -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) { diff --git a/webapp/channels/src/components/suggestion/at_mention_provider/__snapshots__/at_mention_suggestion.test.tsx.snap b/webapp/channels/src/components/suggestion/at_mention_provider/__snapshots__/at_mention_suggestion.test.tsx.snap index 8fb68ed8dd6..9fbb2859783 100644 --- a/webapp/channels/src/components/suggestion/at_mention_provider/__snapshots__/at_mention_suggestion.test.tsx.snap +++ b/webapp/channels/src/components/suggestion/at_mention_provider/__snapshots__/at_mention_suggestion.test.tsx.snap @@ -21,6 +21,7 @@ exports[`at mention suggestion should display nick name of non signed in user 1` { 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'}); diff --git a/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_provider.tsx b/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_provider.tsx index 2234fb5ba5b..d86db4ef3d2 100644 --- a/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_provider.tsx +++ b/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_provider.tsx @@ -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; 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; 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', diff --git a/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_suggestion.tsx b/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_suggestion.tsx index b73e61c7a88..ccc049c0920 100644 --- a/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_suggestion.tsx +++ b/webapp/channels/src/components/suggestion/at_mention_provider/at_mention_suggestion.tsx @@ -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 {'@' + itemname} - {item.is_bot && } + {item.is_bot && {item.isAgent ? : }} {description && {description}} {youElement} {customStatus} diff --git a/webapp/channels/src/components/suggestion/suggestion.tsx b/webapp/channels/src/components/suggestion/suggestion.tsx index 24b2f1c39d6..73056f55301 100644 --- a/webapp/channels/src/components/suggestion/suggestion.tsx +++ b/webapp/channels/src/components/suggestion/suggestion.tsx @@ -30,6 +30,7 @@ const SuggestionContainer = React.forwardRef + {children} + + ); + } + return (
    = { 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 = { export type ProviderResultsGroup = { key: string; - label: MessageDescriptor; + label?: MessageDescriptor; terms: string[]; items: Array; diff --git a/webapp/channels/src/components/textbox.test.tsx b/webapp/channels/src/components/textbox.test.tsx index 8801d79cb55..3f5a4f7677b 100644 --- a/webapp/channels/src/components/textbox.test.tsx +++ b/webapp/channels/src/components/textbox.test.tsx @@ -27,6 +27,7 @@ describe('components/TextBox', () => { autocompleteUsersInChannel: jest.fn(), autocompleteChannels: jest.fn(), searchAssociatedGroupsForReference: jest.fn(), + fetchAgents: jest.fn(), }, useChannelMentions: true, tabIndex: 0, diff --git a/webapp/channels/src/components/textbox/index.ts b/webapp/channels/src/components/textbox/index.ts index dcf7ee074ac..d395dff1780 100644 --- a/webapp/channels/src/components/textbox/index.ts +++ b/webapp/channels/src/components/textbox/index.ts @@ -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), }); diff --git a/webapp/channels/src/components/textbox/textbox.tsx b/webapp/channels/src/components/textbox/textbox.tsx index 6bf8ad946ee..4512c48ac72 100644 --- a/webapp/channels/src/components/textbox/textbox.tsx +++ b/webapp/channels/src/components/textbox/textbox.tsx @@ -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; autocompleteChannels: (term: string, success: (channels: Channel[]) => void, error: () => void) => Promise; searchAssociatedGroupsForReference: (prefix: string, teamId: string, channelId: string | undefined) => Promise<{ data: any }>; + fetchAgents: () => Promise; }; 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 { 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 { 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) => { this.props.onChange(e); }; @@ -139,7 +149,8 @@ export default class Textbox extends React.PureComponent { 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 { 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, }); } } diff --git a/webapp/channels/src/components/widgets/tag/agent_tag.tsx b/webapp/channels/src/components/widgets/tag/agent_tag.tsx new file mode 100644 index 00000000000..16381b7f7c0 --- /dev/null +++ b/webapp/channels/src/components/widgets/tag/agent_tag.tsx @@ -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 ( + + ); +}; + +export default AgentTag; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index a1fd1b4eb62..ba688957867 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -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", diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/agents.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/agents.ts index 64081ef8149..e427cc42ed9 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/agents.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/agents.ts @@ -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 || []; } diff --git a/webapp/channels/src/sass/components/_suggestion-list.scss b/webapp/channels/src/sass/components/_suggestion-list.scss index 37187538b92..50c89854433 100644 --- a/webapp/channels/src/sass/components/_suggestion-list.scss +++ b/webapp/channels/src/sass/components/_suggestion-list.scss @@ -384,3 +384,7 @@ } } } + +.suggestion-list__item--default-agent { + margin-top: 1.2rem; +} diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 976f02e2481..8c867f532db 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -175,6 +175,7 @@ export const TrialPeriodDays = { }; export const suitePluginIds = { + agents: 'mattermost-ai', playbooks: 'playbooks', focalboard: 'focalboard', diff --git a/webapp/platform/types/src/agents.ts b/webapp/platform/types/src/agents.ts index 4080680a07a..46f91063722 100644 --- a/webapp/platform/types/src/agents.ts +++ b/webapp/platform/types/src/agents.ts @@ -7,6 +7,7 @@ export type Agent = { username: string; service_id: string; service_type: string; + is_default?: boolean; }; export type LLMService = {