diff --git a/CLAUDE.md b/CLAUDE.md index 5707699e2..91013f3d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,12 @@ npm run clean # Clean build artifacts **DatabaseManager** singleton (`app/database/manager/index.ts`) manages all instances. Data operations go through operators with transformers/handlers/comparators. +**Sync handlers must handle the full lifecycle: create, update, AND delete.** When a handler syncs data from the server (e.g., `handleAIBots`, `handlePlaybookRuns`), it must also remove records that no longer exist on the server. Compare the full set of existing DB records against incoming data and call `prepareDestroyPermanently()` on stale records. Never write a sync handler that only creates/updates. + +**Important:** Some server APIs do not return deleted records in their response. When the API only returns active records, the handler can diff against existing DB records to detect removals (as shown above). However, if the API returns a *partial* set of records (e.g., paginated without a deletion flag), you may need to request that the API be augmented to include a `delete_at` field or a separate deletions endpoint so that the handler can correctly identify which records to remove. + +**Schema documentation**: When adding or modifying database tables, update `docs/database/server/server.md` (or `app_database/app-database.md`) and bump the schema version number in the header. + **Database directory**: - iOS: App Group directory (shared with extensions) - Android: `${documentDirectory}/databases/` @@ -79,6 +85,7 @@ npm run clean # Clean build artifacts ### Actions Pattern - **Local Actions** (`app/actions/local/`): Database-only operations - **Remote Actions** (`app/actions/remote/`): Fetch from API → use operators to persist → return `{error}` on failure +- **Avoid redundant fetches**: Before fetching user profiles, check the DB first. Use `fetchMissingProfilesByIds()` from `@actions/remote/user` instead of raw `client.getProfilesByIds()` — it queries the DB and only fetches missing profiles ### Query Layer **Query layer** (`app/queries/`) provides both: @@ -254,7 +261,8 @@ Located at `libraries/@mattermost/`: ### React Native & UI - Don't create hooks inside render functions - extract as local components -- Prefer `Button` components over `TouchableOpacity` when appropriate +- **Never use `TouchableOpacity`** — use `Pressable` with pressed feedback instead +- Prefer `Button` components over `Pressable` when appropriate - Non-memoized inline styles add render stress - define in stylesheet instead - **`StyleSheet.create` is unnecessary** when using `makeStyleSheetFromTheme` - just return the plain object - **Place `getStyleSheet` at file top** (after imports, before interfaces/components) not at the bottom @@ -264,11 +272,14 @@ Located at `libraries/@mattermost/`: - Test components with long strings to ensure proper text handling - Use constants from `PREFERENCES.THEMES` instead of hardcoded colors - **Use `react-native-reanimated`** instead of React Native's `Animated` API for all animations (better performance, runs on UI thread) +- **`Pressable` must have pressed feedback**: Always add visual feedback via the style callback, e.g., `style={({pressed}) => [pressed && {opacity: 0.72}]}`. A `Pressable` without pressed state feels broken. +- **Extract static objects used as props into module-level constants**: Objects like `hitSlop`, `contentContainerStyle`, or any non-dynamic prop object should be a `const` outside the component to avoid creating new references on every render. - **Use `usePreventDoubleTap` hook** for button press handlers to prevent accidental double submissions - **Use `useServerUrl()` hook** instead of passing `serverUrl` as a prop - it's available via context - **Use existing components**: Check for `` instead of ``, `safeParseJSON()` instead of try/catch JSON.parse - **Parent checks before mounting**: If a child component would return null for empty data, have the parent conditionally render instead (e.g., `{items.length > 0 && }`) - **Use `@utils/url` utilities**: `tryOpenURL()` instead of `Linking.openURL()`, `getUrlDomain()` with `urlParse` instead of `new URL()` +- **Use `FormattedText`** instead of `{intl.formatMessage(...)}` for static i18n strings. Only fall back to `Text` + `intl.formatMessage` when the text includes dynamic non-translatable content (e.g., a user's display name) ### Code Quality & Linting @@ -290,6 +301,7 @@ Located at `libraries/@mattermost/`: - Use `logError()` instead of `console.error()` or `console.log()` - Use `logDebug()` for debug-level information - Don't ignore potential errors silently - handle them or add intentional comments +- **Log on early returns in handlers**: When a handler returns early (e.g., empty input), add a `logDebug` call so the no-op is traceable - Don't log sensitive information - **Add function/class prefix to logs**: e.g., `logError('[ClassName.methodName]', error)` to make debugging easier @@ -301,7 +313,9 @@ Located at `libraries/@mattermost/`: ### Performance - Create parsers/expensive objects only once, not on every render (use refs or useMemo) - Memoize AST output to avoid regenerating on every render -- Use named constants instead of magic numbers and check if one already exists. +- Use named constants instead of magic numbers and check if one already exists +- **Never use raw `fontSize`, `fontWeight`, or `fontFamily`** in styles — always use `typography()` from `@utils/typography` (e.g., `...typography('Body', 200, 'SemiBold')`) +- Don't set style properties to their default values (e.g., `marginBottom: 0`) — it's dead code ### Localization (i18n) - **CRITICAL**: Only update `en.json` - never modify other language files or Weblate gets corrupted diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index 7e8d38cd0..12a8fc476 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -64,7 +64,7 @@ type AuthorsRequest = { error?: unknown; } -export async function createPost(serverUrl: string, post: Partial, files: FileInfo[] = []): Promise<{data?: boolean; error?: unknown}> { +export async function createPost(serverUrl: string, post: Partial, files: FileInfo[] = []): Promise<{data?: boolean; post?: Post; error?: unknown}> { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; @@ -205,7 +205,7 @@ export async function createPost(serverUrl: string, post: Partial, files: newPost = created; - return {data: true}; + return {data: true, post: created}; } export const retryFailedPost = async (serverUrl: string, post: PostModel) => { diff --git a/app/actions/websocket/event.ts b/app/actions/websocket/event.ts index eea912816..144a14924 100644 --- a/app/actions/websocket/event.ts +++ b/app/actions/websocket/event.ts @@ -3,6 +3,7 @@ import {checkIsAgentsPluginEnabled} from '@agents/actions/remote/agents_status'; import {handleAgentPostUpdate} from '@agents/actions/websocket'; +import {handleAgentsEvents} from '@agents/actions/websocket/events'; import * as bookmark from '@actions/local/channel_bookmark'; import { @@ -346,4 +347,5 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess break; } handlePlaybookEvents(serverUrl, msg); + handleAgentsEvents(serverUrl, msg); } diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index ca8664927..ccf8d3307 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {checkIsAgentsPluginEnabled} from '@agents/actions/remote/agents_status'; +import {handleAgentsReconnect} from '@agents/actions/websocket/reconnect'; import {markChannelAsViewed} from '@actions/local/channel'; import {dataRetentionCleanup, expiredBoRPostCleanup} from '@actions/local/systems'; @@ -92,6 +93,7 @@ async function doReconnect(serverUrl: string, groupLabel?: BaseRequestGroupLabel const config = await getConfig(database); handlePlaybookReconnect(serverUrl); + handleAgentsReconnect(serverUrl); if (isSupportedServerCalls(config?.Version)) { loadConfigAndCalls(serverUrl, currentUserId, groupLabel); diff --git a/app/components/formatted_relative_time/index.tsx b/app/components/formatted_relative_time/index.tsx index d3b0c9f4d..4ffd33a1d 100644 --- a/app/components/formatted_relative_time/index.tsx +++ b/app/components/formatted_relative_time/index.tsx @@ -24,6 +24,11 @@ const FormattedRelativeTime = ({timezone, value, updateIntervalInSeconds, ...pro }; const [formattedTime, setFormattedTime] = useState(getFormattedRelativeTime); + + useEffect(() => { + setFormattedTime(getFormattedRelativeTime()); + }, [value, timezone]); // eslint-disable-line react-hooks/exhaustive-deps -- update display when value or timezone changes + useEffect(() => { if (updateIntervalInSeconds) { const interval = setInterval( diff --git a/app/components/post_draft/draft_handler/draft_handler.tsx b/app/components/post_draft/draft_handler/draft_handler.tsx index a10e39ec8..d5cc4ba7f 100644 --- a/app/components/post_draft/draft_handler/draft_handler.tsx +++ b/app/components/post_draft/draft_handler/draft_handler.tsx @@ -30,6 +30,7 @@ type Props = { updateValue: React.Dispatch>; value: string; setIsFocused: (isFocused: boolean) => void; + onPostCreated?: (postId: string) => void; location?: AvailableScreens; } @@ -51,6 +52,7 @@ export default function DraftHandler(props: Props) { updateValue, value, setIsFocused, + onPostCreated, location, } = props; @@ -118,11 +120,7 @@ export default function DraftHandler(props: Props) { uploadErrorHandlers.current[file.clientId!] = DraftEditPostUploadManager.registerErrorHandler(file.clientId!, newUploadError); } } - - // We don't care about `newUploadError` changes as long as - // it is up to date when the effect runs. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [files]); + }, [files, newUploadError]); return ( ); diff --git a/app/components/post_draft/post_draft.tsx b/app/components/post_draft/post_draft.tsx index 1dd66a830..37ec6c355 100644 --- a/app/components/post_draft/post_draft.tsx +++ b/app/components/post_draft/post_draft.tsx @@ -30,6 +30,7 @@ type Props = { isChannelScreen: boolean; canShowPostPriority?: boolean; location: AvailableScreens; + onPostCreated?: (postId: string) => void; } function PostDraft({ @@ -47,6 +48,7 @@ function PostDraft({ isChannelScreen, canShowPostPriority, location, + onPostCreated, }: Props) { const [value, setValue] = useState(message); const [cursorPosition, setCursorPosition] = useState(message.length); @@ -100,6 +102,7 @@ function PostDraft({ updateValue={setValue} value={value} setIsFocused={setIsFocused} + onPostCreated={onPostCreated} location={location} /> ); diff --git a/app/components/post_draft/send_handler/send_handler.tsx b/app/components/post_draft/send_handler/send_handler.tsx index edf4e5e4a..257891409 100644 --- a/app/components/post_draft/send_handler/send_handler.tsx +++ b/app/components/post_draft/send_handler/send_handler.tsx @@ -53,6 +53,7 @@ type Props = { channelDisplayName?: string; isFromDraftView?: boolean; draftReceiverUserName?: string; + onPostCreated?: (postId: string) => void; location?: AvailableScreens; } @@ -95,6 +96,7 @@ export default function SendHandler({ isFromDraftView, draftType, postId, + onPostCreated, postBoRConfig, location, }: Props) { @@ -123,6 +125,7 @@ export default function SendHandler({ channelType, postPriority, clearDraft, + onPostCreated, postBoRConfig, }); diff --git a/app/constants/database.ts b/app/constants/database.ts index b606f3ed7..fb5d6f0fe 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -77,6 +77,7 @@ export const SYSTEM_IDENTIFIERS = { TEAM_HISTORY: 'teamHistory', WEBSOCKET: 'WebSocket', PLAYBOOKS_VERSION: 'playbooks_version', + AGENTS_VERSION: 'agents_version', LAST_BOR_POST_CLEANUP_RUN: 'lastBoRPostCleanupRun', }; diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 4d8ab628e..63d9efca1 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -7,6 +7,7 @@ import PLAYBOOKS_SCREENS from '@playbooks/constants/screens'; export const ABOUT = 'About'; export const ACCOUNT = 'Account'; +export const AGENTS = 'Agents'; export const APPS_FORM = 'AppForm'; export const ATTACHMENT_OPTIONS = 'AttachmentOptions'; export const BOTTOM_SHEET = 'BottomSheet'; @@ -99,6 +100,7 @@ export const SHOW_TRANSLATION = 'ShowTranslation'; export default { ABOUT, ACCOUNT, + AGENTS, APPS_FORM, ATTACHMENT_OPTIONS, BOTTOM_SHEET, diff --git a/app/database/manager/index.ts b/app/database/manager/index.ts index 317b5320a..bfd8cfdbe 100644 --- a/app/database/manager/index.ts +++ b/app/database/manager/index.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {AiBotModel, AiThreadModel} from '@agents/database/models'; import {Database, Q} from '@nozbe/watermelondb'; import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'; import logger from '@nozbe/watermelondb/utils/common/logger'; @@ -47,6 +48,7 @@ class DatabaseManagerSingleton { constructor() { this.appModels = [InfoModel, GlobalModel, ServersModel]; this.serverModels = [ + AiBotModel, AiThreadModel, CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, CustomProfileFieldModel, CustomProfileAttributeModel, DraftModel, FileModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, diff --git a/app/database/migration/server/index.ts b/app/database/migration/server/index.ts index e4744307a..3273daea1 100644 --- a/app/database/migration/server/index.ts +++ b/app/database/migration/server/index.ts @@ -4,6 +4,7 @@ // NOTE : To implement migration, please follow this document // https://nozbe.github.io/WatermelonDB/Advanced/Migrations.html +import {AGENTS_TABLES} from '@agents/constants/database'; import {addColumns, createTable, schemaMigrations, unsafeExecuteSql} from '@nozbe/watermelondb/Schema/migrations'; import {MM_TABLES} from '@constants/database'; @@ -23,8 +24,38 @@ const { } = MM_TABLES.SERVER; const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; +const {AI_BOT, AI_THREAD} = AGENTS_TABLES; export default schemaMigrations({migrations: [ + { + toVersion: 19, + steps: [ + createTable({ + name: AI_BOT, + columns: [ + {name: 'display_name', type: 'string'}, + {name: 'username', type: 'string'}, + {name: 'last_icon_update', type: 'number'}, + {name: 'dm_channel_id', type: 'string', isIndexed: true}, + {name: 'channel_access_level', type: 'number'}, + {name: 'channel_ids', type: 'string'}, + {name: 'user_access_level', type: 'number'}, + {name: 'user_ids', type: 'string'}, + {name: 'team_ids', type: 'string'}, + ], + }), + createTable({ + name: AI_THREAD, + columns: [ + {name: 'message', type: 'string'}, + {name: 'title', type: 'string'}, + {name: 'channel_id', type: 'string', isIndexed: true}, + {name: 'reply_count', type: 'number'}, + {name: 'update_at', type: 'number', isIndexed: true}, + ], + }), + ], + }, { toVersion: 18, steps: [ diff --git a/app/database/operator/server_data_operator/index.ts b/app/database/operator/server_data_operator/index.ts index 5ce82045e..8a4ebdbda 100644 --- a/app/database/operator/server_data_operator/index.ts +++ b/app/database/operator/server_data_operator/index.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import AgentsHandler, {type AgentsHandlerMix} from '@agents/database/operators/handlers'; + import ServerDataOperatorBase from '@database/operator/server_data_operator/handlers'; import CategoryHandler, {type CategoryHandlerMix} from '@database/operator/server_data_operator/handlers/category'; import ChannelHandler, {type ChannelHandlerMix} from '@database/operator/server_data_operator/handlers/channel'; @@ -17,6 +19,7 @@ import mix from '@utils/mix'; import type {Database} from '@nozbe/watermelondb'; interface ServerDataOperator extends + AgentsHandlerMix, CategoryHandlerMix, ChannelHandlerMix, CustomProfileHandlerMix, @@ -31,6 +34,7 @@ interface ServerDataOperator extends {} class ServerDataOperator extends mix(ServerDataOperatorBase).with( + AgentsHandler, CategoryHandler, ChannelHandler, CustomProfileHandler, diff --git a/app/database/schema/server/index.ts b/app/database/schema/server/index.ts index 54469220a..a1d7af6db 100644 --- a/app/database/schema/server/index.ts +++ b/app/database/schema/server/index.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {AiBotSchema, AiThreadSchema} from '@agents/database/schema'; import {type AppSchema, appSchema} from '@nozbe/watermelondb'; import {PlaybookRunSchema, PlaybookChecklistSchema, PlaybookChecklistItemSchema, PlaybookRunAttributeSchema, PlaybookRunAttributeValueSchema} from '@playbooks/database/schema'; @@ -45,8 +46,10 @@ import { } from './table_schemas'; export const serverSchema: AppSchema = appSchema({ - version: 18, + version: 19, tables: [ + AiBotSchema, + AiThreadSchema, CategorySchema, CategoryChannelSchema, ChannelSchema, diff --git a/app/database/schema/server/test.ts b/app/database/schema/server/test.ts index a95deeec8..c336a00ea 100644 --- a/app/database/schema/server/test.ts +++ b/app/database/schema/server/test.ts @@ -3,11 +3,15 @@ /* eslint-disable max-lines */ +import {AGENTS_TABLES} from '@agents/constants/database'; + import {MM_TABLES} from '@constants/database'; import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; import {serverSchema} from './index'; +const {AI_BOT, AI_THREAD} = AGENTS_TABLES; + const { CATEGORY, CATEGORY_CHANNEL, @@ -52,9 +56,53 @@ const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_A describe('*** Test schema for SERVER database ***', () => { it('=> The SERVER SCHEMA should strictly match', () => { expect(serverSchema).toEqual({ - version: 18, + version: 19, unsafeSql: undefined, tables: { + [AI_BOT]: { + name: AI_BOT, + unsafeSql: undefined, + columns: { + display_name: {name: 'display_name', type: 'string'}, + username: {name: 'username', type: 'string'}, + last_icon_update: {name: 'last_icon_update', type: 'number'}, + dm_channel_id: {name: 'dm_channel_id', type: 'string', isIndexed: true}, + channel_access_level: {name: 'channel_access_level', type: 'number'}, + channel_ids: {name: 'channel_ids', type: 'string'}, + user_access_level: {name: 'user_access_level', type: 'number'}, + user_ids: {name: 'user_ids', type: 'string'}, + team_ids: {name: 'team_ids', type: 'string'}, + }, + columnArray: [ + {name: 'display_name', type: 'string'}, + {name: 'username', type: 'string'}, + {name: 'last_icon_update', type: 'number'}, + {name: 'dm_channel_id', type: 'string', isIndexed: true}, + {name: 'channel_access_level', type: 'number'}, + {name: 'channel_ids', type: 'string'}, + {name: 'user_access_level', type: 'number'}, + {name: 'user_ids', type: 'string'}, + {name: 'team_ids', type: 'string'}, + ], + }, + [AI_THREAD]: { + name: AI_THREAD, + unsafeSql: undefined, + columns: { + message: {name: 'message', type: 'string'}, + title: {name: 'title', type: 'string'}, + channel_id: {name: 'channel_id', type: 'string', isIndexed: true}, + reply_count: {name: 'reply_count', type: 'number'}, + update_at: {name: 'update_at', type: 'number', isIndexed: true}, + }, + columnArray: [ + {name: 'message', type: 'string'}, + {name: 'title', type: 'string'}, + {name: 'channel_id', type: 'string', isIndexed: true}, + {name: 'reply_count', type: 'number'}, + {name: 'update_at', type: 'number', isIndexed: true}, + ], + }, [CATEGORY]: { name: CATEGORY, unsafeSql: undefined, diff --git a/app/hooks/handle_send_message.ts b/app/hooks/handle_send_message.ts index 8b79d355e..066b15550 100644 --- a/app/hooks/handle_send_message.ts +++ b/app/hooks/handle_send_message.ts @@ -53,6 +53,7 @@ type Props = { channelIsArchived?: boolean; channelIsReadOnly?: boolean; deactivatedChannel?: boolean; + onPostCreated?: (postId: string) => void; postBoRConfig?: PostBoRConfig; } @@ -76,6 +77,7 @@ export const useHandleSendMessage = ({ channelIsReadOnly, deactivatedChannel, clearDraft, + onPostCreated, postBoRConfig, }: Props) => { const intl = useIntl(); @@ -158,20 +160,32 @@ export const useHandleSendMessage = ({ return; } - createPost(serverUrl, post, postFiles); + createPost(serverUrl, post, postFiles).then(({post: createdPost}) => { + if (createdPost?.id && onPostCreated) { + // Use post ID or root ID for thread navigation + const threadRootId = createdPost.root_id || createdPost.id; + onPostCreated(threadRootId); + } + }); clearDraft(); // Early return to avoid calling DeviceEventEmitter.emit return; } else { // Response error is handled at the post level so don't have to wait to clear draft - createPost(serverUrl, post, postFiles); + createPost(serverUrl, post, postFiles).then(({post: createdPost}) => { + if (createdPost?.id && onPostCreated) { + // Use post ID or root ID for thread navigation + const threadRootId = createdPost.root_id || createdPost.id; + onPostCreated(threadRootId); + } + }); clearDraft(); } setSendingMessage(false); DeviceEventEmitter.emit(Events.POST_LIST_SCROLL_TO_BOTTOM, rootId ? Screens.THREAD : Screens.CHANNEL); - }, [files, currentUserId, channelId, rootId, value, postPriority, postBoRConfig?.enabled, isFromDraftView, serverUrl, clearDraft, intl, canPost, channelIsArchived, channelIsReadOnly, deactivatedChannel]); + }, [files, currentUserId, channelId, rootId, value, postPriority, postBoRConfig?.enabled, isFromDraftView, serverUrl, intl, canPost, channelIsArchived, channelIsReadOnly, deactivatedChannel, clearDraft, onPostCreated]); const showSendToAllOrChannelOrHereAlert = useCallback((calculatedMembersCount: number, atHere: boolean, schedulingInfo?: SchedulingInfo) => { const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(intl, calculatedMembersCount, channelTimezoneCount, atHere); diff --git a/app/products/agents/actions/local/version.test.ts b/app/products/agents/actions/local/version.test.ts new file mode 100644 index 000000000..5786a8585 --- /dev/null +++ b/app/products/agents/actions/local/version.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import {querySystemValue} from '@queries/servers/system'; + +import {setAgentsVersion} from './version'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; + +const serverUrl = 'agents-local-version.test.com'; +let operator: ServerDataOperator; + +beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + operator = DatabaseManager.serverDatabases[serverUrl]!.operator; +}); + +afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); +}); + +describe('setAgentsVersion', () => { + it('should handle not found database', async () => { + const {error} = await setAgentsVersion('foo', '1.2.3'); + expect(error).toBeTruthy(); + }); + + it('should set agents version successfully', async () => { + const version = '2.0.0'; + const {data, error} = await setAgentsVersion(serverUrl, version); + expect(error).toBeUndefined(); + expect(data).toBe(true); + + const systemValues = await querySystemValue(operator.database, SYSTEM_IDENTIFIERS.AGENTS_VERSION); + expect(systemValues[0].value).toBe(version); + }); + + it('should handle operator errors', async () => { + const originalHandleSystem = operator.handleSystem; + operator.handleSystem = jest.fn().mockImplementation(() => { + throw new Error('fail'); + }); + const {error} = await setAgentsVersion(serverUrl, '3.0.0'); + expect(error).toBeTruthy(); + operator.handleSystem = originalHandleSystem; + }); + +}); diff --git a/app/products/agents/actions/local/version.ts b/app/products/agents/actions/local/version.ts new file mode 100644 index 000000000..a80e24f7d --- /dev/null +++ b/app/products/agents/actions/local/version.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; + +export const setAgentsVersion = async (serverUrl: string, version: string) => { + try { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + await operator.handleSystem({ + systems: [{ + id: SYSTEM_IDENTIFIERS.AGENTS_VERSION, + value: version, + }], + prepareRecordsOnly: false, + }); + + return {data: true}; + } catch (error) { + return {error}; + } +}; diff --git a/app/products/agents/actions/remote/bots.test.ts b/app/products/agents/actions/remote/bots.test.ts new file mode 100644 index 000000000..9dbc3e0d1 --- /dev/null +++ b/app/products/agents/actions/remote/bots.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fetchMissingProfilesByIds} from '@actions/remote/user'; +import NetworkManager from '@managers/network_manager'; +import {getFullErrorMessage} from '@utils/errors'; +import {logError} from '@utils/log'; + +import {fetchAIBots} from './bots'; + +const mockOperator = { + handleAIBots: jest.fn(), +}; + +jest.mock('@database/manager', () => ({ + getServerDatabaseAndOperator: jest.fn(() => ({ + operator: mockOperator, + })), +})); +jest.mock('@actions/remote/user'); +jest.mock('@managers/network_manager'); +jest.mock('@utils/errors'); +jest.mock('@utils/log'); + +const serverUrl = 'https://test.mattermost.com'; + +const mockClient = { + getAIBots: jest.fn(), +}; + +beforeAll(() => { + jest.mocked(NetworkManager.getClient).mockReturnValue(mockClient as any); +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('fetchAIBots', () => { + it('should persist bots to database and return config flags', async () => { + const mockResponse = { + bots: [{id: 'bot1', name: 'Test Bot'}], + searchEnabled: true, + allowUnsafeLinks: false, + }; + mockClient.getAIBots.mockResolvedValue(mockResponse); + + const result = await fetchAIBots(serverUrl); + + expect(mockClient.getAIBots).toHaveBeenCalled(); + expect(mockOperator.handleAIBots).toHaveBeenCalledWith({ + bots: mockResponse.bots, + prepareRecordsOnly: false, + }); + expect(result.bots).toEqual(mockResponse.bots); + expect(result.searchEnabled).toBe(true); + expect(result.allowUnsafeLinks).toBe(false); + expect(result.error).toBeUndefined(); + }); + + it('should refresh missing bot user profiles on success', async () => { + const mockResponse = { + bots: [{id: 'bot1', name: 'Test Bot'}], + searchEnabled: false, + allowUnsafeLinks: false, + }; + mockClient.getAIBots.mockResolvedValue(mockResponse); + + await fetchAIBots(serverUrl); + + expect(fetchMissingProfilesByIds).toHaveBeenCalledWith(serverUrl, ['bot1']); + }); + + it('should handle profile refresh failure gracefully', async () => { + const mockResponse = { + bots: [{id: 'bot1', name: 'Test Bot'}], + searchEnabled: false, + allowUnsafeLinks: false, + }; + mockClient.getAIBots.mockResolvedValue(mockResponse); + jest.mocked(fetchMissingProfilesByIds).mockRejectedValue(new Error('profile fetch failed')); + + const result = await fetchAIBots(serverUrl); + + // Should still succeed — the inner try/catch handles profile errors + expect(result.error).toBeUndefined(); + expect(result.bots).toEqual(mockResponse.bots); + }); + + it('should return error and log on failure', async () => { + const error = new Error('Network error'); + const errorMessage = 'Network error occurred'; + mockClient.getAIBots.mockRejectedValue(error); + jest.mocked(getFullErrorMessage).mockReturnValue(errorMessage); + + const result = await fetchAIBots(serverUrl); + + expect(logError).toHaveBeenCalledWith('[fetchAIBots] Failed to fetch AI bots', error); + expect(getFullErrorMessage).toHaveBeenCalledWith(error); + expect(result).toEqual({error: errorMessage}); + }); +}); diff --git a/app/products/agents/actions/remote/bots.ts b/app/products/agents/actions/remote/bots.ts new file mode 100644 index 000000000..0b0a6e0aa --- /dev/null +++ b/app/products/agents/actions/remote/bots.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fetchMissingProfilesByIds} from '@actions/remote/user'; +import DatabaseManager from '@database/manager'; +import NetworkManager from '@managers/network_manager'; +import {getFullErrorMessage} from '@utils/errors'; +import {logDebug, logError} from '@utils/log'; + +import type {LLMBot} from '@agents/types'; + +/** + * Fetch all AI bots from the server and store them in the database + * @param serverUrl The server URL + * @returns {bots, searchEnabled, allowUnsafeLinks, error} - Bot configuration on success, error on failure + */ +export async function fetchAIBots( + serverUrl: string, +): Promise<{bots?: LLMBot[]; searchEnabled?: boolean; allowUnsafeLinks?: boolean; error?: unknown}> { + try { + const client = NetworkManager.getClient(serverUrl); + const response = await client.getAIBots(); + + // Store bots in database and remove any that no longer exist on the server + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + await operator.handleAIBots({ + bots: response.bots, + prepareRecordsOnly: false, + }); + + // Refresh bot user profiles to keep User.deleteAt current. + // This prevents stale deactivation status from showing "archived channel" in agent chat. + // Only fetches profiles not already in the DB. + const botUserIds = (response.bots || []).map((b) => b.id).filter(Boolean); + if (botUserIds.length) { + try { + await fetchMissingProfilesByIds(serverUrl, botUserIds); + } catch (profileError) { + logDebug('[fetchAIBots] Failed to refresh bot user profiles', getFullErrorMessage(profileError)); + } + } + + return { + bots: response.bots, + searchEnabled: response.searchEnabled, + allowUnsafeLinks: response.allowUnsafeLinks, + }; + } catch (error) { + logError('[fetchAIBots] Failed to fetch AI bots', error); + return {error: getFullErrorMessage(error)}; + } +} diff --git a/app/products/agents/actions/remote/channel_summary.ts b/app/products/agents/actions/remote/channel_summary.ts index fbb97b7c4..25f1dfcf6 100644 --- a/app/products/agents/actions/remote/channel_summary.ts +++ b/app/products/agents/actions/remote/channel_summary.ts @@ -6,7 +6,7 @@ import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; import {getMyChannel} from '@queries/servers/channel'; import {getFullErrorMessage} from '@utils/errors'; -import {logError} from '@utils/log'; +import {logDebug, logError} from '@utils/log'; import type {ChannelAnalysisOptions, ChannelAnalysisResponse} from '@agents/types/api'; @@ -28,6 +28,7 @@ export async function requestChannelSummary( // Use empty string for teamId - fetchMyChannel will use channel.team_id if available const channelResult = await fetchMyChannel(serverUrl, '', channelId); if (channelResult.error) { + logDebug('[requestChannelSummary] Failed to fetch channel', getFullErrorMessage(channelResult.error)); return {error: getFullErrorMessage(channelResult.error)}; } } @@ -36,6 +37,7 @@ export async function requestChannelSummary( const result = await client.doChannelAnalysis(channelId, analysisType, botUsername, options); if (!result?.postid || !result?.channelid) { + logDebug('[requestChannelSummary] Invalid response - missing postid or channelid'); return {error: 'Invalid response from server'}; } diff --git a/app/products/agents/actions/remote/threads.test.ts b/app/products/agents/actions/remote/threads.test.ts new file mode 100644 index 000000000..85c8a2c41 --- /dev/null +++ b/app/products/agents/actions/remote/threads.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import NetworkManager from '@managers/network_manager'; +import {getFullErrorMessage} from '@utils/errors'; +import {logError} from '@utils/log'; + +import {fetchAIThreads} from './threads'; + +const mockOperator = { + handleAIThreads: jest.fn(), +}; + +jest.mock('@database/manager', () => ({ + getServerDatabaseAndOperator: jest.fn(() => ({ + operator: mockOperator, + })), +})); +jest.mock('@managers/network_manager'); +jest.mock('@utils/errors'); +jest.mock('@utils/log'); + +const serverUrl = 'https://test.mattermost.com'; + +const mockClient = { + getAIThreads: jest.fn(), +}; + +beforeAll(() => { + jest.mocked(NetworkManager.getClient).mockReturnValue(mockClient as any); +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('fetchAIThreads', () => { + it('should persist threads to database and return them', async () => { + const mockThreads = [ + {id: 'thread1', channelId: 'channel1'}, + {id: 'thread2', channelId: 'channel2'}, + ]; + mockClient.getAIThreads.mockResolvedValue(mockThreads); + + const result = await fetchAIThreads(serverUrl); + + expect(mockClient.getAIThreads).toHaveBeenCalled(); + expect(mockOperator.handleAIThreads).toHaveBeenCalledWith({ + threads: mockThreads, + prepareRecordsOnly: false, + }); + expect(result.threads).toEqual(mockThreads); + expect(result.error).toBeUndefined(); + }); + + it('should normalize null API response to empty array', async () => { + mockClient.getAIThreads.mockResolvedValue(null); + + const result = await fetchAIThreads(serverUrl); + + expect(mockOperator.handleAIThreads).toHaveBeenCalledWith({ + threads: [], + prepareRecordsOnly: false, + }); + expect(result.threads).toEqual([]); + expect(result.error).toBeUndefined(); + }); + + it('should return error and log on failure', async () => { + const error = new Error('Network error'); + const errorMessage = 'Failed to fetch threads'; + mockClient.getAIThreads.mockRejectedValue(error); + jest.mocked(getFullErrorMessage).mockReturnValue(errorMessage); + + const result = await fetchAIThreads(serverUrl); + + expect(logError).toHaveBeenCalledWith('[fetchAIThreads] Failed to fetch AI threads', error); + expect(getFullErrorMessage).toHaveBeenCalledWith(error); + expect(result).toEqual({error: errorMessage}); + }); +}); diff --git a/app/products/agents/actions/remote/threads.ts b/app/products/agents/actions/remote/threads.ts new file mode 100644 index 000000000..0dc0fbb74 --- /dev/null +++ b/app/products/agents/actions/remote/threads.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; +import NetworkManager from '@managers/network_manager'; +import {getFullErrorMessage} from '@utils/errors'; +import {logError} from '@utils/log'; + +import type {AIThread} from '@agents/types'; + +/** + * Fetch all AI threads (conversations with agent bots) from the server and store them in the database + * @param serverUrl The server URL + * @returns {threads, error} - Array of AI threads on success, error on failure + */ +export async function fetchAIThreads( + serverUrl: string, +): Promise<{threads?: AIThread[]; error?: unknown}> { + try { + const client = NetworkManager.getClient(serverUrl); + const response = await client.getAIThreads(); + + // Handle null/undefined response from API - treat as empty array + const threads = Array.isArray(response) ? response : []; + + // Store threads in database and remove any that no longer exist on the server + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + await operator.handleAIThreads({ + threads, + prepareRecordsOnly: false, + }); + + return {threads}; + } catch (error) { + logError('[fetchAIThreads] Failed to fetch AI threads', error); + return {error: getFullErrorMessage(error)}; + } +} diff --git a/app/products/agents/actions/remote/version.test.ts b/app/products/agents/actions/remote/version.test.ts new file mode 100644 index 000000000..68d47c397 --- /dev/null +++ b/app/products/agents/actions/remote/version.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {setAgentsVersion} from '@agents/actions/local/version'; +import {AGENTS_PLUGIN_ID} from '@agents/constants/plugin'; + +import {forceLogoutIfNecessary} from '@actions/remote/session'; +import DatabaseManager from '@database/manager'; +import NetworkManager from '@managers/network_manager'; + +import {updateAgentsVersion} from './version'; + +const serverUrl = 'agents-remote-version.test.com'; + +const mockManifest = { + id: AGENTS_PLUGIN_ID, + version: '2.0.0', +}; + +jest.mock('@agents/actions/local/version'); +jest.mock('@actions/remote/session'); + +const mockClient = { + getPluginsManifests: jest.fn(), +}; + +beforeAll(() => { + (NetworkManager.getClient as jest.Mock) = jest.fn(() => mockClient); +}); + +beforeEach(async () => { + await DatabaseManager.init([serverUrl]); +}); + +afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); +}); + +describe('updateAgentsVersion', () => { + it('should handle client error and force logout if necessary', async () => { + const error = new Error('error'); + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(() => { + throw error; + }); + + const result = await updateAgentsVersion(serverUrl); + + expect(result.error).toBe(error); + expect(forceLogoutIfNecessary).toHaveBeenCalledWith(serverUrl, error); + }); + + it('should update agents version successfully when manifest found', async () => { + mockClient.getPluginsManifests.mockResolvedValueOnce([mockManifest]); + + const result = await updateAgentsVersion(serverUrl); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.data).toBe(true); + expect(setAgentsVersion).toHaveBeenCalledWith(serverUrl, '2.0.0'); + }); + + it('should handle when agents manifest not found', async () => { + mockClient.getPluginsManifests.mockResolvedValueOnce([ + {id: 'other-plugin', version: '1.0.0'}, + ]); + + const result = await updateAgentsVersion(serverUrl); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.data).toBe(true); + + // setAgentsVersion not called because current version ('') equals new version ('') + expect(setAgentsVersion).not.toHaveBeenCalled(); + }); + +}); diff --git a/app/products/agents/actions/remote/version.ts b/app/products/agents/actions/remote/version.ts new file mode 100644 index 000000000..ee3dfcf2a --- /dev/null +++ b/app/products/agents/actions/remote/version.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {setAgentsVersion} from '@agents/actions/local/version'; +import {AGENTS_PLUGIN_ID} from '@agents/constants/plugin'; +import {fetchAgentsVersion} from '@agents/database/queries/version'; + +import {forceLogoutIfNecessary} from '@actions/remote/session'; +import DatabaseManager from '@database/manager'; +import NetworkManager from '@managers/network_manager'; +import {getFullErrorMessage} from '@utils/errors'; +import {logDebug} from '@utils/log'; + +export const updateAgentsVersion = async (serverUrl: string) => { + try { + const client = NetworkManager.getClient(serverUrl); + const manifests = await client.getPluginsManifests(); + const manifest = manifests.find((m) => m.id === AGENTS_PLUGIN_ID); + const newVersion = manifest?.version || ''; + + // Only update if version is different + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const currentVersion = await fetchAgentsVersion(database); + if (currentVersion !== newVersion) { + await setAgentsVersion(serverUrl, newVersion); + } + + return {data: true}; + } catch (error) { + logDebug('error on isAgentsEnabled', getFullErrorMessage(error)); + await forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; diff --git a/app/products/agents/actions/websocket/events.test.ts b/app/products/agents/actions/websocket/events.test.ts new file mode 100644 index 000000000..8ddd648ed --- /dev/null +++ b/app/products/agents/actions/websocket/events.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {AGENTS_PLUGIN_ID} from '@agents/constants/plugin'; + +import {WebsocketEvents} from '@constants'; + +import {handleAgentsEvents} from './events'; +import {handleAgentsPluginEnabled, handleAgentsPluginDisabled} from './version'; + +const serverUrl = 'test-server.com'; + +jest.mock('./version'); + +describe('handleAgentsEvents', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call handleAgentsPluginEnabled when PLUGIN_ENABLED event is received', async () => { + const manifest: ClientPluginManifest = { + id: AGENTS_PLUGIN_ID, + version: '2.0.0', + webapp: { + bundle_path: '/static/agents.js', + }, + }; + + const msg: WebSocketMessage = { + event: WebsocketEvents.PLUGIN_ENABLED, + data: {manifest}, + broadcast: { + channel_id: '', + team_id: '', + user_id: '', + omit_users: {}, + }, + seq: 1, + }; + + await handleAgentsEvents(serverUrl, msg); + + expect(handleAgentsPluginEnabled).toHaveBeenCalledWith(serverUrl, manifest); + expect(handleAgentsPluginEnabled).toHaveBeenCalledTimes(1); + expect(handleAgentsPluginDisabled).not.toHaveBeenCalled(); + }); + + it('should call handleAgentsPluginDisabled when PLUGIN_DISABLED event is received', async () => { + const manifest: ClientPluginManifest = { + id: AGENTS_PLUGIN_ID, + version: '2.0.0', + webapp: { + bundle_path: '/static/agents.js', + }, + }; + + const msg: WebSocketMessage = { + event: WebsocketEvents.PLUGIN_DISABLED, + data: {manifest}, + broadcast: { + channel_id: '', + team_id: '', + user_id: '', + omit_users: {}, + }, + seq: 1, + }; + + await handleAgentsEvents(serverUrl, msg); + + expect(handleAgentsPluginDisabled).toHaveBeenCalledWith(serverUrl, manifest); + expect(handleAgentsPluginDisabled).toHaveBeenCalledTimes(1); + expect(handleAgentsPluginEnabled).not.toHaveBeenCalled(); + }); + + it('should not call any handler for unrelated events', async () => { + const msg: WebSocketMessage = { + event: 'some_other_event', + data: {}, + broadcast: { + channel_id: '', + team_id: '', + user_id: '', + omit_users: {}, + }, + seq: 1, + }; + + await handleAgentsEvents(serverUrl, msg); + + expect(handleAgentsPluginEnabled).not.toHaveBeenCalled(); + expect(handleAgentsPluginDisabled).not.toHaveBeenCalled(); + }); +}); diff --git a/app/products/agents/actions/websocket/events.ts b/app/products/agents/actions/websocket/events.ts new file mode 100644 index 000000000..39b4347a7 --- /dev/null +++ b/app/products/agents/actions/websocket/events.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {WebsocketEvents} from '@constants'; + +import {handleAgentsPluginDisabled, handleAgentsPluginEnabled} from './version'; + +export async function handleAgentsEvents(serverUrl: string, msg: WebSocketMessage) { + switch (msg.event) { + case WebsocketEvents.PLUGIN_ENABLED: + handleAgentsPluginEnabled(serverUrl, msg.data.manifest); + break; + case WebsocketEvents.PLUGIN_DISABLED: + handleAgentsPluginDisabled(serverUrl, msg.data.manifest); + break; + default: + break; + } +} diff --git a/app/products/agents/actions/websocket/reconnect.test.ts b/app/products/agents/actions/websocket/reconnect.test.ts new file mode 100644 index 000000000..8a22df7e8 --- /dev/null +++ b/app/products/agents/actions/websocket/reconnect.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {updateAgentsVersion} from '@agents/actions/remote/version'; + +import DatabaseManager from '@database/manager'; +import {logDebug} from '@utils/log'; + +import {handleAgentsReconnect} from './reconnect'; + +const serverUrl = 'test-server.com'; + +jest.mock('@agents/actions/remote/version'); +jest.mock('@utils/log'); + +describe('handleAgentsReconnect', () => { + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + + jest.mocked(updateAgentsVersion).mockResolvedValue({data: true}); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should return early if database is not found', async () => { + await DatabaseManager.deleteServerDatabase(serverUrl); + + await handleAgentsReconnect(serverUrl); + + expect(updateAgentsVersion).not.toHaveBeenCalled(); + }); + + it('should update agents version', async () => { + await handleAgentsReconnect(serverUrl); + + expect(updateAgentsVersion).toHaveBeenCalledWith(serverUrl); + expect(updateAgentsVersion).toHaveBeenCalledTimes(1); + }); + + it('should handle error from updateAgentsVersion', async () => { + const error = new Error('Update error'); + jest.mocked(updateAgentsVersion).mockResolvedValueOnce({error}); + + await handleAgentsReconnect(serverUrl); + + expect(updateAgentsVersion).toHaveBeenCalledWith(serverUrl); + expect(updateAgentsVersion).toHaveBeenCalledTimes(1); + expect(logDebug).toHaveBeenCalledWith('Error updating agents version on reconnect', error); + }); +}); diff --git a/app/products/agents/actions/websocket/reconnect.ts b/app/products/agents/actions/websocket/reconnect.ts new file mode 100644 index 000000000..cc84db33f --- /dev/null +++ b/app/products/agents/actions/websocket/reconnect.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {updateAgentsVersion} from '@agents/actions/remote/version'; + +import DatabaseManager from '@database/manager'; +import {logDebug} from '@utils/log'; + +export async function handleAgentsReconnect(serverUrl: string) { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return; + } + + // Set the version of the agents plugin to the systems table + const updateResult = await updateAgentsVersion(serverUrl); + if (updateResult.error) { + logDebug('Error updating agents version on reconnect', updateResult.error); + } +} diff --git a/app/products/agents/actions/websocket/version.test.ts b/app/products/agents/actions/websocket/version.test.ts new file mode 100644 index 000000000..7542d5e49 --- /dev/null +++ b/app/products/agents/actions/websocket/version.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {setAgentsVersion} from '@agents/actions/local/version'; +import {AGENTS_PLUGIN_ID} from '@agents/constants/plugin'; + +import { + handleAgentsPluginEnabled, + handleAgentsPluginDisabled, +} from './version'; + +const serverUrl = 'test-server.com'; + +jest.mock('@agents/actions/local/version'); + +describe('handleAgentsPluginEnabled', () => { + it('should set agents version when plugin is enabled with correct manifest', async () => { + const manifest: ClientPluginManifest = { + id: AGENTS_PLUGIN_ID, + version: '2.0.0', + webapp: { + bundle_path: '/static/agents.js', + }, + }; + + await handleAgentsPluginEnabled(serverUrl, manifest); + + expect(setAgentsVersion).toHaveBeenCalledWith(serverUrl, '2.0.0'); + expect(setAgentsVersion).toHaveBeenCalledTimes(1); + }); + + it('should not set agents version when manifest id does not match agents plugin id', async () => { + const manifest: ClientPluginManifest = { + id: 'other-plugin', + version: '1.0.0', + webapp: { + bundle_path: '/static/other.js', + }, + }; + + await handleAgentsPluginEnabled(serverUrl, manifest); + + expect(setAgentsVersion).not.toHaveBeenCalled(); + }); + + it('should handle empty version string', async () => { + const manifest: ClientPluginManifest = { + id: AGENTS_PLUGIN_ID, + version: '', + webapp: { + bundle_path: '/static/agents.js', + }, + }; + + await handleAgentsPluginEnabled(serverUrl, manifest); + + expect(setAgentsVersion).toHaveBeenCalledWith(serverUrl, ''); + }); +}); + +describe('handleAgentsPluginDisabled', () => { + it('should clear agents version when plugin is disabled with correct manifest', async () => { + const manifest: ClientPluginManifest = { + id: AGENTS_PLUGIN_ID, + version: '2.0.0', + webapp: { + bundle_path: '/static/agents.js', + }, + }; + + await handleAgentsPluginDisabled(serverUrl, manifest); + + expect(setAgentsVersion).toHaveBeenCalledWith(serverUrl, ''); + }); + + it('should not clear agents version when manifest id does not match agents plugin id', async () => { + const manifest: ClientPluginManifest = { + id: 'other-plugin', + version: '1.0.0', + webapp: { + bundle_path: '/static/other.js', + }, + }; + + await handleAgentsPluginDisabled(serverUrl, manifest); + + expect(setAgentsVersion).not.toHaveBeenCalled(); + }); +}); diff --git a/app/products/agents/actions/websocket/version.ts b/app/products/agents/actions/websocket/version.ts new file mode 100644 index 000000000..b9befb3fe --- /dev/null +++ b/app/products/agents/actions/websocket/version.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {setAgentsVersion} from '@agents/actions/local/version'; +import {AGENTS_PLUGIN_ID} from '@agents/constants/plugin'; + +export async function handleAgentsPluginEnabled(serverUrl: string, manifest: ClientPluginManifest) { + if (manifest.id !== AGENTS_PLUGIN_ID) { + return; + } + setAgentsVersion(serverUrl, manifest.version); +} + +export async function handleAgentsPluginDisabled(serverUrl: string, manifest: ClientPluginManifest) { + if (manifest.id !== AGENTS_PLUGIN_ID) { + return; + } + setAgentsVersion(serverUrl, ''); +} diff --git a/app/products/agents/client/rest.test.ts b/app/products/agents/client/rest.test.ts new file mode 100644 index 000000000..35587e6e0 --- /dev/null +++ b/app/products/agents/client/rest.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import clientAgents from './rest'; + +describe('ClientAgents', () => { + const mockDoFetch = jest.fn(); + + const BaseClass = class { + doFetch = mockDoFetch; + }; + const Client = clientAgents(BaseClass); + const client = new Client(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getAgentsRoute', () => { + it('should return the correct route', () => { + expect(client.getAgentsRoute()).toBe('/plugins/mattermost-ai'); + }); + }); + + describe('getAIBots', () => { + it('should make correct API call', async () => { + await client.getAIBots(); + expect(mockDoFetch).toHaveBeenCalledWith( + '/plugins/mattermost-ai/ai_bots', + {method: 'get'}, + ); + }); + }); + + describe('getAIThreads', () => { + it('should make correct API call', async () => { + await client.getAIThreads(); + expect(mockDoFetch).toHaveBeenCalledWith( + '/plugins/mattermost-ai/ai_threads', + {method: 'get'}, + ); + }); + }); + + describe('stopGeneration', () => { + it('should make correct API call', async () => { + await client.stopGeneration('post-123'); + expect(mockDoFetch).toHaveBeenCalledWith( + '/plugins/mattermost-ai/post/post-123/stop', + {method: 'post'}, + ); + }); + }); + + describe('regenerateResponse', () => { + it('should make correct API call', async () => { + await client.regenerateResponse('post-456'); + expect(mockDoFetch).toHaveBeenCalledWith( + '/plugins/mattermost-ai/post/post-456/regenerate', + {method: 'post'}, + ); + }); + }); + + describe('submitToolApproval', () => { + it('should make correct API call with accepted tool IDs', async () => { + const acceptedToolIds = ['tool-1', 'tool-2']; + await client.submitToolApproval('post-789', acceptedToolIds); + expect(mockDoFetch).toHaveBeenCalledWith( + '/plugins/mattermost-ai/post/post-789/tool_call', + { + method: 'post', + body: {accepted_tool_ids: acceptedToolIds}, + }, + ); + }); + + it('should make correct API call with empty tool IDs', async () => { + await client.submitToolApproval('post-789', []); + expect(mockDoFetch).toHaveBeenCalledWith( + '/plugins/mattermost-ai/post/post-789/tool_call', + { + method: 'post', + body: {accepted_tool_ids: []}, + }, + ); + }); + }); +}); diff --git a/app/products/agents/client/rest.ts b/app/products/agents/client/rest.ts index 5bcea9ed4..1cebf854f 100644 --- a/app/products/agents/client/rest.ts +++ b/app/products/agents/client/rest.ts @@ -1,13 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {ToolCall} from '@agents/types'; +import type {AIBotsResponse, AIThread, ToolCall} from '@agents/types'; import type {Agent, AgentsResponse, AgentsStatusResponse, ChannelAnalysisOptions, ChannelAnalysisResponse, RewriteRequest, RewriteResponse} from '@agents/types/api'; export type {Agent}; export interface ClientAgentsMix { getAgentsRoute: () => string; + getAIBots: () => Promise; + getAIThreads: () => Promise; getAgents: () => Promise; stopGeneration: (postId: string) => Promise; regenerateResponse: (postId: string) => Promise; @@ -32,6 +34,20 @@ const ClientAgents = (superclass: any) => class extends superclass { return '/plugins/mattermost-ai'; }; + getAIBots = async () => { + return this.doFetch( + `${this.getAgentsRoute()}/ai_bots`, + {method: 'get'}, + ); + }; + + getAIThreads = async () => { + return this.doFetch( + `${this.getAgentsRoute()}/ai_threads`, + {method: 'get'}, + ); + }; + getAgents = async (): Promise => { const response = await this.doFetch( `${this.urlVersion}/agents`, diff --git a/app/products/agents/components/agent_post/agent_post.tsx b/app/products/agents/components/agent_post/agent_post.tsx index 635697cc8..43a4cb53b 100644 --- a/app/products/agents/components/agent_post/agent_post.tsx +++ b/app/products/agents/components/agent_post/agent_post.tsx @@ -17,6 +17,7 @@ import {useTheme} from '@context/theme'; import {safeParseJSON} from '@utils/helpers'; import {showSnackBar} from '@utils/snack_bar'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; import CitationsList from '../citations_list'; import ControlsBar from '../controls_bar'; @@ -40,8 +41,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }, messageText: { color: theme.centerChannelColor, - fontSize: 15, - lineHeight: 20, + ...typography('Body', 200), }, precontentContainer: { flexDirection: 'row', @@ -50,9 +50,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }, precontentText: { color: changeOpacity(theme.centerChannelColor, 0.6), - fontSize: 14, fontStyle: 'italic', marginRight: 8, + ...typography('Body', 100), }, }; }); diff --git a/app/products/agents/components/agents_button/index.tsx b/app/products/agents/components/agents_button/index.tsx new file mode 100644 index 000000000..0f06bbad6 --- /dev/null +++ b/app/products/agents/components/agents_button/index.tsx @@ -0,0 +1,107 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {goToAgentChat} from '@agents/screens/navigation'; +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {DeviceEventEmitter, Pressable, View} from 'react-native'; + +import { + getStyleSheet as getChannelItemStyleSheet, + ROW_HEIGHT, + textStyle as channelItemTextStyle, +} from '@components/channel_item/channel_item'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {Events} from '@constants'; +import {AGENTS} from '@constants/screens'; +import {HOME_PADDING} from '@constants/view'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {usePreventDoubleTap} from '@hooks/utils'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + shouldHighlightActive?: boolean; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + icon: { + color: changeOpacity(theme.sidebarText, 0.5), + marginRight: 12, + ...typography('Body', 500), + }, + iconActive: { + color: theme.sidebarText, + }, + text: { + flex: 1, + }, +})); + +const AgentsButton = ({ + shouldHighlightActive = false, +}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const isTablet = useIsTablet(); + const commonChannelItemStyles = getChannelItemStyleSheet(theme); + const styles = getStyleSheet(theme); + + const isActive = isTablet && shouldHighlightActive; + + const handlePress = usePreventDoubleTap(useCallback(() => { + DeviceEventEmitter.emit(Events.ACTIVE_SCREEN, AGENTS); + goToAgentChat(intl); + }, [intl])); + + const [containerStyle, iconStyle, textStyle] = useMemo(() => { + const container = [ + commonChannelItemStyles.container, + HOME_PADDING, + isActive && commonChannelItemStyles.activeItem, + isActive && { + paddingLeft: HOME_PADDING.paddingLeft - commonChannelItemStyles.activeItem.borderLeftWidth, + }, + {minHeight: ROW_HEIGHT}, + ]; + + const icon = [ + styles.icon, + isActive && styles.iconActive, + ]; + + const text = [ + styles.text, + channelItemTextStyle.regular, + commonChannelItemStyles.text, + isActive && commonChannelItemStyles.textActive, + ]; + + return [container, icon, text]; + }, [styles, commonChannelItemStyles, isActive]); + + return ( + [pressed && {opacity: 0.72}]} + onPress={handlePress} + testID='channel_list.agents.button' + > + + + + + + ); +}; + +export default React.memo(AgentsButton); diff --git a/app/products/agents/components/channel_summary_sheet/agent_item.tsx b/app/products/agents/components/channel_summary_sheet/agent_item.tsx index 4f6f8a0cf..aa0ad5a66 100644 --- a/app/products/agents/components/channel_summary_sheet/agent_item.tsx +++ b/app/products/agents/components/channel_summary_sheet/agent_item.tsx @@ -3,7 +3,7 @@ import {type Agent} from '@agents/client/rest'; import React, {useCallback, useMemo} from 'react'; -import {Text, TouchableOpacity, View} from 'react-native'; +import {Pressable, Text, View} from 'react-native'; import CompassIcon from '@components/compass_icon'; import {ExpoImageAnimated} from '@components/expo_image'; @@ -68,9 +68,9 @@ const AgentItem = React.memo(({agent, profileImageUrl, currentAgentUsername, onS const cacheId = agent.id ? `user-${agent.id}` : undefined; return ( - [styles.agentRow, pressed && {opacity: 0.72}]} testID={`agents.selector.agent.${agent.username}`} > @@ -100,7 +100,7 @@ const AgentItem = React.memo(({agent, profileImageUrl, currentAgentUsername, onS color={theme.linkColor} /> )} - + ); }); AgentItem.displayName = 'AgentItem'; diff --git a/app/products/agents/components/channel_summary_sheet/agent_selector_panel.tsx b/app/products/agents/components/channel_summary_sheet/agent_selector_panel.tsx index b9d7e2f6b..88172a128 100644 --- a/app/products/agents/components/channel_summary_sheet/agent_selector_panel.tsx +++ b/app/products/agents/components/channel_summary_sheet/agent_selector_panel.tsx @@ -3,7 +3,7 @@ import {type Agent} from '@agents/client/rest'; import React, {useCallback, useMemo} from 'react'; -import {FlatList, type ListRenderItemInfo, TouchableOpacity, View} from 'react-native'; +import {FlatList, type ListRenderItemInfo, Pressable, View} from 'react-native'; import {buildAbsoluteUrl} from '@actions/remote/file'; import {buildProfileImageUrl} from '@actions/remote/user'; @@ -106,9 +106,9 @@ const AgentSelectorPanel = ({ const renderHeader = useCallback(() => ( - [styles.backButton, pressed && {opacity: 0.72}]} testID='agents.selector.back' > - + - [styles.dateBox, isActive && styles.dateBoxActive, pressed && {opacity: 0.72}]} testID={testID} > {date ? ( @@ -122,7 +122,7 @@ const DateInputField = ({label, date, placeholder, onPress, testID, styles, isAc style={styles.datePlaceholder} /> )} - + ); @@ -209,9 +209,9 @@ const DateRangePicker = ({onSubmit, onCancel}: Props) => { {/* Header with back button */} - [styles.backButton, pressed && {opacity: 0.72}]} hitSlop={BACK_BUTTON_HIT_SLOP} testID='agents.channel_summary.date_picker.back' > @@ -220,7 +220,7 @@ const DateRangePicker = ({onSubmit, onCancel}: Props) => { size={24} color={theme.centerChannelColor} /> - + { {/* Header Section - Agent selector + Prompt input */} - [styles.agentRow, pressed && {opacity: 0.72}]} testID='agents.channel_summary.agent_selector' disabled={submitting || loadingAgents} > @@ -325,7 +325,7 @@ const ChannelSummarySheet = ({channelId}: Props) => { )} - + { onSubmitEditing={handleCustomPromptSubmitDebounced} returnKeyType='send' endAdornment={ - [ styles.sendButton, (!customPrompt.trim() || submitting) && styles.sendButtonDisabled, + pressed && {opacity: 0.72}, ]} disabled={!customPrompt.trim() || submitting} testID='agents.channel_summary.prompt_submit' @@ -352,7 +353,7 @@ const ChannelSummarySheet = ({channelId}: Props) => { size={20} color={theme.buttonColor} /> - + } /> diff --git a/app/products/agents/components/citations_list/citations_list.test.tsx b/app/products/agents/components/citations_list/citations_list.test.tsx new file mode 100644 index 000000000..6d3a05d87 --- /dev/null +++ b/app/products/agents/components/citations_list/citations_list.test.tsx @@ -0,0 +1,172 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {type ComponentProps} from 'react'; + +import {fireEvent, renderWithIntlAndTheme} from '@test/intl-test-helper'; +import {tryOpenURL} from '@utils/url'; + +import CitationsList from './index'; + +import type {Annotation} from '@agents/types'; + +jest.mock('@utils/url', () => ({ + tryOpenURL: jest.fn(), + getUrlDomain: jest.fn((url: string) => { + try { + return new URL(url).hostname; + } catch { + return url; + } + }), +})); + +describe('CitationsList', () => { + const createMockAnnotation = (overrides: Partial = {}): Annotation => ({ + type: 'url_citation', + start_index: 0, + end_index: 10, + url: 'https://example.com/article', + title: 'Example Article', + index: 1, + ...overrides, + }); + + const getBaseProps = (): ComponentProps => ({ + annotations: [createMockAnnotation()], + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('header rendering', () => { + it('should render citation count in header', () => { + const props = getBaseProps(); + props.annotations = [ + createMockAnnotation({index: 1}), + createMockAnnotation({index: 2, url: 'https://other.com'}), + ]; + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Sources (2)')).toBeTruthy(); + }); + + it('should render single citation count', () => { + const props = getBaseProps(); + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Sources (1)')).toBeTruthy(); + }); + }); + + describe('expand/collapse', () => { + it('should render toggle button', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntlAndTheme(); + + expect(getByTestId('citations.list.toggle')).toBeTruthy(); + }); + + it('should render citation items (always mounted for animation)', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntlAndTheme(); + + // Items are always in the tree; collapse is handled via animated height + expect(getByTestId('citations.list.item.1')).toBeTruthy(); + }); + + it('should toggle without error when pressed multiple times', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntlAndTheme(); + + fireEvent.press(getByTestId('citations.list.toggle')); + fireEvent.press(getByTestId('citations.list.toggle')); + + // Items remain accessible after toggling + expect(getByTestId('citations.list.item.1')).toBeTruthy(); + }); + }); + + describe('citation items', () => { + it('should render citation with title', () => { + const props = getBaseProps(); + props.annotations = [ + createMockAnnotation({title: 'My Article Title'}), + ]; + const {getByTestId, getByText} = renderWithIntlAndTheme(); + + fireEvent.press(getByTestId('citations.list.toggle')); + expect(getByText('My Article Title')).toBeTruthy(); + }); + + it('should render domain when no title provided', () => { + const props = getBaseProps(); + props.annotations = [ + createMockAnnotation({title: '', url: 'https://docs.example.com/page'}), + ]; + const {getByTestId, getAllByText} = renderWithIntlAndTheme(); + + fireEvent.press(getByTestId('citations.list.toggle')); + + // Domain appears twice - once as title fallback and once as URL below + // This is expected behavior per the component design + expect(getAllByText('docs.example.com').length).toBe(2); + }); + + it('should render domain URL below title', () => { + const props = getBaseProps(); + props.annotations = [ + createMockAnnotation({url: 'https://api.example.org/docs'}), + ]; + const {getByTestId, getByText} = renderWithIntlAndTheme(); + + fireEvent.press(getByTestId('citations.list.toggle')); + expect(getByText('api.example.org')).toBeTruthy(); + }); + + it('should render multiple citation items', () => { + const props = getBaseProps(); + props.annotations = [ + createMockAnnotation({index: 1, title: 'First Article'}), + createMockAnnotation({index: 2, title: 'Second Article', url: 'https://other.com'}), + createMockAnnotation({index: 3, title: 'Third Article', url: 'https://third.com'}), + ]; + const {getByTestId, getByText} = renderWithIntlAndTheme(); + + fireEvent.press(getByTestId('citations.list.toggle')); + + expect(getByText('First Article')).toBeTruthy(); + expect(getByText('Second Article')).toBeTruthy(); + expect(getByText('Third Article')).toBeTruthy(); + }); + }); + + describe('URL handling', () => { + it('should call tryOpenURL when citation pressed', () => { + const props = getBaseProps(); + props.annotations = [ + createMockAnnotation({url: 'https://example.com/specific-page'}), + ]; + const {getByTestId} = renderWithIntlAndTheme(); + + fireEvent.press(getByTestId('citations.list.toggle')); + fireEvent.press(getByTestId('citations.list.item.1')); + + expect(tryOpenURL).toHaveBeenCalledWith('https://example.com/specific-page'); + }); + + it('should not call tryOpenURL for empty URL', () => { + const props = getBaseProps(); + props.annotations = [ + createMockAnnotation({url: ''}), + ]; + const {getByTestId} = renderWithIntlAndTheme(); + + fireEvent.press(getByTestId('citations.list.toggle')); + fireEvent.press(getByTestId('citations.list.item.1')); + + expect(tryOpenURL).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/products/agents/components/citations_list/index.tsx b/app/products/agents/components/citations_list/index.tsx index d990ced72..ff104ba2d 100644 --- a/app/products/agents/components/citations_list/index.tsx +++ b/app/products/agents/components/citations_list/index.tsx @@ -3,13 +3,14 @@ import {TOUCH_TARGET_SIZE} from '@agents/constants'; import React, {useCallback, useEffect, useState} from 'react'; -import {type LayoutChangeEvent, Text, TouchableOpacity, View} from 'react-native'; +import {type LayoutChangeEvent, Pressable, Text, View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; import {getUrlDomain, tryOpenURL} from '@utils/url'; import type {Annotation} from '@agents/types'; @@ -35,10 +36,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { flex: 1, }, headerText: { - fontSize: 14, - fontWeight: 600, color: changeOpacity(theme.centerChannelColor, 0.72), marginLeft: 8, + ...typography('Body', 100, 'SemiBold'), }, citationsList: { overflow: 'hidden', @@ -71,14 +71,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { marginRight: 8, }, citationTitle: { - fontSize: 14, - fontWeight: 600, color: theme.centerChannelColor, marginBottom: 2, + ...typography('Body', 100, 'SemiBold'), }, citationUrl: { - fontSize: 12, color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 75), }, }; }); @@ -128,9 +127,9 @@ const CitationsList = ({annotations}: CitationsListProps) => { return ( - [styles.header, pressed && {opacity: 0.72}]} testID='citations.list.toggle' > @@ -151,7 +150,7 @@ const CitationsList = ({annotations}: CitationsListProps) => { size={20} color={changeOpacity(theme.centerChannelColor, 0.64)} /> - + { style={styles.citationsContentWrapper} > {annotations.map((annotation) => ( - handleCitationPress(annotation.url)} - style={styles.citationItem} + style={({pressed}) => [styles.citationItem, pressed && {opacity: 0.72}]} testID={`citations.list.item.${annotation.index}`} > @@ -191,7 +190,7 @@ const CitationsList = ({annotations}: CitationsListProps) => { size={16} color={changeOpacity(theme.centerChannelColor, 0.56)} /> - + ))} diff --git a/app/products/agents/components/controls_bar/controls_bar.test.tsx b/app/products/agents/components/controls_bar/controls_bar.test.tsx new file mode 100644 index 000000000..f6a1f6e4f --- /dev/null +++ b/app/products/agents/components/controls_bar/controls_bar.test.tsx @@ -0,0 +1,150 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {type ComponentProps} from 'react'; +import {Alert} from 'react-native'; + +import {fireEvent, renderWithIntlAndTheme} from '@test/intl-test-helper'; + +import ControlsBar from './index'; + +jest.spyOn(Alert, 'alert'); + +describe('ControlsBar', () => { + const getBaseProps = (): ComponentProps => ({ + showStopButton: false, + showRegenerateButton: false, + onStop: jest.fn(), + onRegenerate: jest.fn(), + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('button visibility', () => { + it('should show stop button when showStopButton is true', () => { + const props = getBaseProps(); + props.showStopButton = true; + const {getByTestId} = renderWithIntlAndTheme(); + + expect(getByTestId('agents.controls_bar.stop_button')).toBeTruthy(); + }); + + it('should hide stop button when showStopButton is false', () => { + const props = getBaseProps(); + props.showStopButton = false; + const {queryByTestId} = renderWithIntlAndTheme(); + + expect(queryByTestId('agents.controls_bar.stop_button')).toBeNull(); + }); + + it('should show regenerate button when showRegenerateButton is true', () => { + const props = getBaseProps(); + props.showRegenerateButton = true; + const {getByTestId} = renderWithIntlAndTheme(); + + expect(getByTestId('agents.controls_bar.regenerate_button')).toBeTruthy(); + }); + + it('should hide regenerate button when showRegenerateButton is false', () => { + const props = getBaseProps(); + props.showRegenerateButton = false; + const {queryByTestId} = renderWithIntlAndTheme(); + + expect(queryByTestId('agents.controls_bar.regenerate_button')).toBeNull(); + }); + + }); + + describe('button text', () => { + it('should render Stop Generating text', () => { + const props = getBaseProps(); + props.showStopButton = true; + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Stop Generating')).toBeTruthy(); + }); + + it('should render Regenerate text', () => { + const props = getBaseProps(); + props.showRegenerateButton = true; + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Regenerate')).toBeTruthy(); + }); + }); + + describe('stop button callback', () => { + it('should call onStop when stop button pressed', () => { + const props = getBaseProps(); + props.showStopButton = true; + const {getByTestId} = renderWithIntlAndTheme(); + + fireEvent.press(getByTestId('agents.controls_bar.stop_button')); + + expect(props.onStop).toHaveBeenCalled(); + }); + }); + + describe('regenerate button with confirmation', () => { + it('should show Alert confirmation when regenerate pressed', () => { + const props = getBaseProps(); + props.showRegenerateButton = true; + const {getByTestId} = renderWithIntlAndTheme(); + + fireEvent.press(getByTestId('agents.controls_bar.regenerate_button')); + + expect(Alert.alert).toHaveBeenCalledWith( + 'Regenerate Response', + 'This will clear the current response and generate a new one. Continue?', + expect.arrayContaining([ + expect.objectContaining({text: 'Cancel', style: 'cancel'}), + expect.objectContaining({text: 'Regenerate', style: 'destructive'}), + ]), + ); + }); + + it('should not call onRegenerate immediately when regenerate pressed', () => { + const props = getBaseProps(); + props.showRegenerateButton = true; + const {getByTestId} = renderWithIntlAndTheme(); + + fireEvent.press(getByTestId('agents.controls_bar.regenerate_button')); + + // onRegenerate should not be called yet - waiting for confirmation + expect(props.onRegenerate).not.toHaveBeenCalled(); + }); + + it('should call onRegenerate when confirmation is accepted', () => { + const props = getBaseProps(); + props.showRegenerateButton = true; + const {getByTestId} = renderWithIntlAndTheme(); + + fireEvent.press(getByTestId('agents.controls_bar.regenerate_button')); + + // Get the alert call and simulate pressing "Regenerate" + const alertCall = (Alert.alert as jest.Mock).mock.calls[0]; + const buttons = alertCall[2]; + const regenerateButton = buttons.find((b: {text: string}) => b.text === 'Regenerate'); + regenerateButton.onPress(); + + expect(props.onRegenerate).toHaveBeenCalled(); + }); + + it('should not call onRegenerate when cancel is pressed', () => { + const props = getBaseProps(); + props.showRegenerateButton = true; + const {getByTestId} = renderWithIntlAndTheme(); + + fireEvent.press(getByTestId('agents.controls_bar.regenerate_button')); + + const alertCall = (Alert.alert as jest.Mock).mock.calls[0]; + const buttons = alertCall[2]; + const cancelButton = buttons.find((b: {text: string}) => b.text === 'Cancel'); + + // Cancel button intentionally has no handler — dismissal is the default behavior + expect(cancelButton.onPress).toBeUndefined(); + }); + }); +}); diff --git a/app/products/agents/components/controls_bar/index.tsx b/app/products/agents/components/controls_bar/index.tsx index 1cd9e1701..2d050e0cd 100644 --- a/app/products/agents/components/controls_bar/index.tsx +++ b/app/products/agents/components/controls_bar/index.tsx @@ -3,13 +3,14 @@ import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; -import {Alert, TouchableOpacity, View} from 'react-native'; +import {Alert, Pressable, View} from 'react-native'; import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; import {useTheme} from '@context/theme'; import {usePreventDoubleTap} from '@hooks/utils'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { @@ -29,10 +30,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { justifyContent: 'center', }, buttonText: { - fontSize: 12, - fontWeight: 600, - lineHeight: 16, color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 75, 'SemiBold'), }, }; }); @@ -92,10 +91,9 @@ const ControlsBar = ({ return ( {showStopButton && ( - [styles.button, pressed && {opacity: 0.72}]} testID='agents.controls_bar.stop_button' > - + )} {showRegenerateButton && ( - [styles.button, pressed && {opacity: 0.72}]} testID='agents.controls_bar.regenerate_button' > - + )} ); diff --git a/app/products/agents/components/illustrations/ai_copilot_intro.tsx b/app/products/agents/components/illustrations/ai_copilot_intro.tsx new file mode 100644 index 000000000..af2dfc668 --- /dev/null +++ b/app/products/agents/components/illustrations/ai_copilot_intro.tsx @@ -0,0 +1,232 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import Svg, {Circle, ClipPath, Defs, Ellipse, G, Line, Path, Rect} from 'react-native-svg'; + +type Props = { + theme: Theme; +}; + +const AgentsIntro = ({theme}: Props) => ( + + + {/* Background ellipse */} + + {/* Lightbulb fill */} + + {/* Lightbulb stroke */} + + {/* Lightbulb base */} + + {/* Lightbulb internal lines */} + + + {/* Heartbeat line */} + + {/* Checklist background */} + + {/* Checklist item 1 circle */} + + {/* Checklist item 1 checkmark */} + + {/* Checklist item 1 lines */} + + + {/* Checklist item 2 circle */} + + {/* Checklist item 2 checkmark */} + + {/* Checklist item 2 lines */} + + + {/* Decorative stars */} + + + + {/* Chat bubble fill */} + + {/* Chat bubble overlay */} + + {/* Chat bubble lines */} + + + + + {/* Magnifying glass circle */} + + {/* Magnifying glass reflection */} + + {/* Magnifying glass handle */} + + {/* Bottom decorative lines */} + + + {/* Decorative arcs around lightbulb */} + + + + + + + + + +); + +export default AgentsIntro; diff --git a/app/products/agents/components/illustrations/index.ts b/app/products/agents/components/illustrations/index.ts new file mode 100644 index 000000000..3bfbb5cb3 --- /dev/null +++ b/app/products/agents/components/illustrations/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {default as AgentsIntro} from './ai_copilot_intro'; diff --git a/app/products/agents/components/reasoning_display/index.tsx b/app/products/agents/components/reasoning_display/index.tsx index f206e2561..e8e1e265c 100644 --- a/app/products/agents/components/reasoning_display/index.tsx +++ b/app/products/agents/components/reasoning_display/index.tsx @@ -3,13 +3,14 @@ import {TOUCH_TARGET_SIZE} from '@agents/constants'; import React, {useCallback, useState} from 'react'; -import {Text, TouchableOpacity, View} from 'react-native'; +import {Pressable, Text, View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; import LoadingSpinner from './loading_spinner'; @@ -18,6 +19,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { minimalContainer: { marginBottom: 4, minHeight: TOUCH_TARGET_SIZE, + marginLeft: -14, }, minimalContent: { flexDirection: 'row', @@ -26,12 +28,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { paddingVertical: 12, }, minimalText: { - fontSize: 14, - lineHeight: 20, color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 100), }, expandedContainer: { marginBottom: 16, + marginLeft: -15, }, expandedHeader: { flexDirection: 'row', @@ -42,9 +44,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { paddingVertical: 12, }, expandedHeaderText: { - fontSize: 14, - lineHeight: 20, color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 100), }, reasoningContentContainer: { backgroundColor: changeOpacity(theme.centerChannelColor, 0.02), @@ -58,9 +59,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }, reasoningText: { padding: 16, - fontSize: 14, - lineHeight: 22, color: changeOpacity(theme.centerChannelColor, 0.8), + ...typography('Body', 100), }, }; }); @@ -98,10 +98,9 @@ const ReasoningDisplay = ({reasoningSummary, isReasoningLoading}: ReasoningDispl return ( - [isExpanded ? styles.expandedHeader : styles.minimalContent, pressed && {opacity: 0.72}]} > - + {isExpanded && reasoningSummary ? ( diff --git a/app/products/agents/components/reasoning_display/reasoning_display.test.tsx b/app/products/agents/components/reasoning_display/reasoning_display.test.tsx new file mode 100644 index 000000000..932b809e8 --- /dev/null +++ b/app/products/agents/components/reasoning_display/reasoning_display.test.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {type ComponentProps} from 'react'; + +import {fireEvent, renderWithIntlAndTheme} from '@test/intl-test-helper'; + +import ReasoningDisplay from './index'; + +// Mock LoadingSpinner with testID for easy querying +jest.mock('./loading_spinner', () => { + const {View} = require('react-native'); + const MockLoadingSpinner = () => ; + MockLoadingSpinner.displayName = 'MockLoadingSpinner'; + return MockLoadingSpinner; +}); + +describe('ReasoningDisplay', () => { + const getBaseProps = (): ComponentProps => ({ + reasoningSummary: 'I am thinking about the problem step by step...', + isReasoningLoading: false, + }); + + describe('header rendering', () => { + it('should render "Thinking" header text', () => { + const props = getBaseProps(); + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Thinking')).toBeTruthy(); + }); + + it('should show loading spinner when isReasoningLoading is true', () => { + const props = getBaseProps(); + props.isReasoningLoading = true; + const {getByTestId} = renderWithIntlAndTheme(); + + // LoadingSpinner component should be present + expect(getByTestId('loading-spinner')).toBeTruthy(); + }); + + it('should not show loading spinner when isReasoningLoading is false', () => { + const props = getBaseProps(); + props.isReasoningLoading = false; + const {queryByTestId} = renderWithIntlAndTheme(); + + // LoadingSpinner component should not be present + expect(queryByTestId('loading-spinner')).toBeNull(); + }); + }); + + describe('expand/collapse', () => { + it('should be collapsed by default', () => { + const props = getBaseProps(); + const {queryByText} = renderWithIntlAndTheme(); + + // Reasoning text should not be visible when collapsed + expect(queryByText('I am thinking about the problem step by step...')).toBeNull(); + }); + + it('should expand when header pressed', () => { + const props = getBaseProps(); + const {getByText} = renderWithIntlAndTheme(); + + fireEvent.press(getByText('Thinking')); + + // Reasoning text should now be visible + expect(getByText('I am thinking about the problem step by step...')).toBeTruthy(); + }); + + it('should collapse when header pressed again', () => { + const props = getBaseProps(); + const {getByText, queryByText} = renderWithIntlAndTheme(); + + // Expand + fireEvent.press(getByText('Thinking')); + expect(getByText('I am thinking about the problem step by step...')).toBeTruthy(); + + // Collapse + fireEvent.press(getByText('Thinking')); + expect(queryByText('I am thinking about the problem step by step...')).toBeNull(); + }); + }); + + describe('reasoning content', () => { + it('should render reasoning text when expanded', () => { + const props = getBaseProps(); + props.reasoningSummary = 'Step 1: Analyze the input. Step 2: Process data.'; + const {getByText} = renderWithIntlAndTheme(); + + fireEvent.press(getByText('Thinking')); + + expect(getByText('Step 1: Analyze the input. Step 2: Process data.')).toBeTruthy(); + }); + + it('should not render content area when reasoning is empty and expanded', () => { + const props = getBaseProps(); + props.reasoningSummary = ''; + const {getByText, queryByText} = renderWithIntlAndTheme(); + + fireEvent.press(getByText('Thinking')); + + // Empty reasoning should not show any text content + expect(queryByText('I am thinking')).toBeNull(); + }); + + it('should handle long reasoning text', () => { + const props = getBaseProps(); + props.reasoningSummary = 'A'.repeat(1000); // Long text + const {getByText} = renderWithIntlAndTheme(); + + fireEvent.press(getByText('Thinking')); + + expect(getByText('A'.repeat(1000))).toBeTruthy(); + }); + }); + +}); diff --git a/app/products/agents/components/rewriting_indicator/index.tsx b/app/products/agents/components/rewriting_indicator/index.tsx index e5b1e5c78..b4a22c29a 100644 --- a/app/products/agents/components/rewriting_indicator/index.tsx +++ b/app/products/agents/components/rewriting_indicator/index.tsx @@ -3,10 +3,10 @@ import {useRewrite} from '@agents/hooks'; import React, {useEffect} from 'react'; -import {useIntl} from 'react-intl'; -import {StyleSheet, Text, View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import Animated, {cancelAnimation, Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming} from 'react-native-reanimated'; +import FormattedText from '@components/formatted_text'; import StatusIndicator from '@components/post_draft/status_indicator'; import {useTheme} from '@context/theme'; import {changeOpacity} from '@utils/theme'; @@ -40,7 +40,6 @@ const getStyleSheet = (theme: Theme) => StyleSheet.create({ function RewritingIndicator() { const {isProcessing} = useRewrite(); const theme = useTheme(); - const intl = useIntl(); const styles = getStyleSheet(theme); const rotation = useSharedValue(0); @@ -65,12 +64,11 @@ function RewritingIndicator() { - - {intl.formatMessage({ - id: 'ai_rewrite.rewriting', - defaultMessage: 'Rewriting...', - })} - + ); diff --git a/app/products/agents/components/tool_approval_set/index.tsx b/app/products/agents/components/tool_approval_set/index.tsx index aaa27ce2d..6d7e6ec52 100644 --- a/app/products/agents/components/tool_approval_set/index.tsx +++ b/app/products/agents/components/tool_approval_set/index.tsx @@ -14,6 +14,7 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {showSnackBar} from '@utils/snack_bar'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; import ToolCard from '../tool_card'; @@ -36,6 +37,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { container: { marginTop: 8, marginBottom: 12, + marginLeft: -15, gap: 8, }, statusBar: { @@ -48,8 +50,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { borderRadius: 4, }, statusText: { - fontSize: 12, color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 75), }, }; }); diff --git a/app/products/agents/components/tool_card/index.tsx b/app/products/agents/components/tool_card/index.tsx index 1fa9df7d6..b4592742a 100644 --- a/app/products/agents/components/tool_card/index.tsx +++ b/app/products/agents/components/tool_card/index.tsx @@ -3,7 +3,7 @@ import {ToolApprovalStage, ToolCallStatus, type ToolCall} from '@agents/types'; import React, {useCallback, useEffect, useMemo} from 'react'; -import {Text, TouchableOpacity, View} from 'react-native'; +import {Pressable, Text, View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import CompassIcon from '@components/compass_icon'; @@ -17,6 +17,12 @@ import {safeParseJSON} from '@utils/helpers'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; +const HIT_SLOP_VERTICAL = 4; + +// Indent to align content under the tool name (past the chevron icon column) +const CONTENT_INDENT = 13; +const BUTTON_HIT_SLOP = {top: HIT_SLOP_VERTICAL, bottom: HIT_SLOP_VERTICAL, left: 0, right: 0}; + interface ToolCardProps { tool: ToolCall; isCollapsed: boolean; @@ -34,7 +40,6 @@ interface ToolCardProps { const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { container: { - marginBottom: 4, }, header: { flexDirection: 'row', @@ -55,7 +60,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { ...typography('Body', 100), }, argumentsContainer: { - marginLeft: 24, + marginLeft: CONTENT_INDENT, }, markdownText: { color: changeOpacity(theme.centerChannelColor, 0.75), @@ -67,21 +72,21 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { gap: 8, paddingTop: 8, paddingBottom: 8, - paddingLeft: 24, + paddingLeft: CONTENT_INDENT, }, responseLabelText: { color: changeOpacity(theme.centerChannelColor, 0.75), ...typography('Body', 100), }, resultContainer: { - marginLeft: 24, + marginLeft: CONTENT_INDENT, }, statusContainer: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 16, - paddingLeft: 24, + paddingLeft: CONTENT_INDENT, }, statusText: { color: changeOpacity(theme.centerChannelColor, 0.75), @@ -105,6 +110,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { paddingVertical: 8, paddingHorizontal: 16, justifyContent: 'center', + alignItems: 'center', + minHeight: 32, }, buttonDisabled: { opacity: 0.5, @@ -306,10 +313,9 @@ const ToolCard = ({ style={styles.container} testID={testIdPrefix} > - [styles.header, canExpand && pressed && {opacity: 0.72}]} testID={`${testIdPrefix}.header`} > {canExpand ? ( @@ -329,7 +335,7 @@ const ToolCard = ({ > {displayName} - + {!isCollapsed && ( @@ -447,11 +453,11 @@ const ToolCard = ({ {isPending && !hasLocalDecision && !isProcessing && onApprove && onReject && ( - [styles.button, isProcessing && styles.buttonDisabled, pressed && {opacity: 0.72}]} + hitSlop={BUTTON_HIT_SLOP} testID={`${testIdPrefix}.approve`} > - - + [styles.button, isProcessing && styles.buttonDisabled, pressed && {opacity: 0.72}]} + hitSlop={BUTTON_HIT_SLOP} testID={`${testIdPrefix}.reject`} > - + )} {isResultPhase && (isSuccess || isError) && !hasLocalDecision && !isProcessing && onApprove && onReject && ( - [styles.shareButton, isProcessing && styles.buttonDisabled, pressed && {opacity: 0.72}]} testID={`${testIdPrefix}.share`} > - - + [styles.keepPrivateButton, isProcessing && styles.buttonDisabled, pressed && {opacity: 0.72}]} testID={`${testIdPrefix}.keep_private`} > - + )} diff --git a/app/products/agents/components/tool_card/tool_card.test.tsx b/app/products/agents/components/tool_card/tool_card.test.tsx new file mode 100644 index 000000000..06fc15e5f --- /dev/null +++ b/app/products/agents/components/tool_card/tool_card.test.tsx @@ -0,0 +1,303 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ToolApprovalStage, ToolCallStatus, type ToolCall} from '@agents/types'; +import React, {type ComponentProps} from 'react'; + +import {fireEvent, renderWithIntlAndTheme} from '@test/intl-test-helper'; + +import ToolCard from './index'; + +// Mock Markdown component to avoid database dependency +jest.mock('@components/markdown', () => { + const {Text} = require('react-native'); + const MockMarkdown = ({value}: {value: string}) => ( + {value} + ); + return MockMarkdown; +}); + +describe('ToolCard', () => { + const createMockTool = (overrides: Partial = {}): ToolCall => ({ + id: 'tool-123', + name: 'search_documents', + description: 'Search through documents', + arguments: {query: 'test query'}, + status: ToolCallStatus.Pending, + ...overrides, + }); + + const getBaseProps = (): ComponentProps => ({ + tool: createMockTool(), + isCollapsed: false, + isProcessing: false, + onToggleCollapse: jest.fn(), + onApprove: jest.fn(), + onReject: jest.fn(), + approvalStage: null, + }); + + describe('tool name display', () => { + it('should transform underscores to spaces and capitalize', () => { + const props = getBaseProps(); + props.tool = createMockTool({name: 'search_documents'}); + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Search Documents')).toBeTruthy(); + }); + + it('should handle single word names', () => { + const props = getBaseProps(); + props.tool = createMockTool({name: 'search'}); + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Search')).toBeTruthy(); + }); + + it('should handle multiple underscores', () => { + const props = getBaseProps(); + props.tool = createMockTool({name: 'get_user_profile_data'}); + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Get User Profile Data')).toBeTruthy(); + }); + }); + + describe('status-based rendering', () => { + it('should render pending tool with name visible', () => { + const props = getBaseProps(); + props.tool = createMockTool({status: ToolCallStatus.Pending}); + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Search Documents')).toBeTruthy(); + }); + + it('should show response section for success status', () => { + const props = getBaseProps(); + props.tool = createMockTool({ + status: ToolCallStatus.Success, + result: 'Success result', + }); + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Response')).toBeTruthy(); + }); + + it('should show response section for error status', () => { + const props = getBaseProps(); + props.tool = createMockTool({ + status: ToolCallStatus.Error, + result: 'Error occurred', + }); + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Response')).toBeTruthy(); + }); + + it('should not show response section when no result', () => { + const props = getBaseProps(); + props.tool = createMockTool({ + status: ToolCallStatus.Success, + result: undefined, + }); + props.isCollapsed = false; + const {queryByText} = renderWithIntlAndTheme(); + + expect(queryByText('Response')).toBeNull(); + }); + + it('should show rejected status text for rejected status', () => { + const props = getBaseProps(); + props.tool = createMockTool({status: ToolCallStatus.Rejected}); + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Rejected')).toBeTruthy(); + }); + }); + + describe('button visibility', () => { + it('should show approve/reject buttons for pending without local decision', () => { + const props = getBaseProps(); + props.tool = createMockTool({status: ToolCallStatus.Pending}); + props.localDecision = undefined; + props.isProcessing = false; + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Accept')).toBeTruthy(); + expect(getByText('Reject')).toBeTruthy(); + }); + + it('should hide buttons when local decision is made (approved)', () => { + const props = getBaseProps(); + props.tool = createMockTool({status: ToolCallStatus.Pending}); + props.localDecision = true; + const {queryByText} = renderWithIntlAndTheme(); + + expect(queryByText('Accept')).toBeNull(); + expect(queryByText('Reject')).toBeNull(); + }); + + it('should hide buttons when local decision is made (rejected)', () => { + const props = getBaseProps(); + props.tool = createMockTool({status: ToolCallStatus.Pending}); + props.localDecision = false; + const {queryByText} = renderWithIntlAndTheme(); + + expect(queryByText('Accept')).toBeNull(); + expect(queryByText('Reject')).toBeNull(); + }); + + it('should show processing text when pending and processing', () => { + const props = getBaseProps(); + props.tool = createMockTool({status: ToolCallStatus.Pending}); + props.isProcessing = true; + props.localDecision = undefined; + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Processing...')).toBeTruthy(); + }); + + it('should hide buttons for non-pending status', () => { + const props = getBaseProps(); + props.tool = createMockTool({status: ToolCallStatus.Success}); + const {queryByText} = renderWithIntlAndTheme(); + + expect(queryByText('Accept')).toBeNull(); + expect(queryByText('Reject')).toBeNull(); + }); + }); + + describe('callbacks', () => { + it('should call onToggleCollapse with tool id when header pressed', () => { + const props = getBaseProps(); + const {getByText} = renderWithIntlAndTheme(); + + // Press on the tool name area (header) + fireEvent.press(getByText('Search Documents')); + expect(props.onToggleCollapse).toHaveBeenCalledWith('tool-123'); + }); + + it('should call onApprove with tool id when Accept pressed', () => { + const props = getBaseProps(); + props.tool = createMockTool({status: ToolCallStatus.Pending}); + const {getByText} = renderWithIntlAndTheme(); + + fireEvent.press(getByText('Accept')); + expect(props.onApprove).toHaveBeenCalledWith('tool-123'); + }); + + it('should call onReject with tool id when Reject pressed', () => { + const props = getBaseProps(); + props.tool = createMockTool({status: ToolCallStatus.Pending}); + const {getByText} = renderWithIntlAndTheme(); + + fireEvent.press(getByText('Reject')); + expect(props.onReject).toHaveBeenCalledWith('tool-123'); + }); + }); + + describe('collapse state', () => { + it('should show content when not collapsed', () => { + const props = getBaseProps(); + props.isCollapsed = false; + const {queryAllByTestId} = renderWithIntlAndTheme(); + + // Mocked Markdown should be present when expanded + expect(queryAllByTestId('mock-markdown').length).toBeGreaterThan(0); + }); + + it('should hide content when collapsed', () => { + const props = getBaseProps(); + props.isCollapsed = true; + const {queryAllByTestId} = renderWithIntlAndTheme(); + + // Mocked Markdown should not be present when collapsed + expect(queryAllByTestId('mock-markdown').length).toBe(0); + }); + }); + + describe('result phase UI', () => { + const getResultPhaseProps = (): ComponentProps => ({ + ...getBaseProps(), + tool: createMockTool({ + status: ToolCallStatus.Success, + result: 'Search completed successfully', + }), + approvalStage: ToolApprovalStage.Result, + isCollapsed: false, + isProcessing: false, + localDecision: undefined, + }); + + it('should show share and keep private buttons in result phase with success status', () => { + const props = getResultPhaseProps(); + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Share')).toBeTruthy(); + expect(getByText('Keep private')).toBeTruthy(); + }); + + it('should show share and keep private buttons in result phase with error status', () => { + const props = getResultPhaseProps(); + props.tool = createMockTool({ + status: ToolCallStatus.Error, + result: 'An error occurred', + }); + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Share')).toBeTruthy(); + expect(getByText('Keep private')).toBeTruthy(); + }); + + it('should not show result phase buttons when not in result phase', () => { + const props = getResultPhaseProps(); + props.approvalStage = null; + const {queryByText} = renderWithIntlAndTheme(); + + expect(queryByText('Share')).toBeNull(); + expect(queryByText('Keep private')).toBeNull(); + }); + + it('should not show result phase buttons when local decision is made', () => { + const props = getResultPhaseProps(); + props.localDecision = true; + const {queryByText} = renderWithIntlAndTheme(); + + expect(queryByText('Share')).toBeNull(); + expect(queryByText('Keep private')).toBeNull(); + }); + + it('should call onApprove when share is pressed', () => { + const props = getResultPhaseProps(); + const {getByText} = renderWithIntlAndTheme(); + + fireEvent.press(getByText('Share')); + expect(props.onApprove).toHaveBeenCalledWith('tool-123'); + }); + + it('should call onReject when keep private is pressed', () => { + const props = getResultPhaseProps(); + const {getByText} = renderWithIntlAndTheme(); + + fireEvent.press(getByText('Keep private')); + expect(props.onReject).toHaveBeenCalledWith('tool-123'); + }); + + it('should show warning callout in result phase with results', () => { + const props = getResultPhaseProps(); + const {getByTestId, getByText} = renderWithIntlAndTheme(); + + expect(getByTestId('agents.tool_card.tool-123.warning')).toBeTruthy(); + expect(getByText('Review tool response')).toBeTruthy(); + }); + + it('should not show warning callout when not in result phase', () => { + const props = getResultPhaseProps(); + props.approvalStage = null; + const {queryByTestId} = renderWithIntlAndTheme(); + + expect(queryByTestId('agents.tool_card.tool-123.warning')).toBeNull(); + }); + }); + +}); diff --git a/app/products/agents/constants/database.ts b/app/products/agents/constants/database.ts new file mode 100644 index 000000000..75fb09cb4 --- /dev/null +++ b/app/products/agents/constants/database.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const AGENTS_TABLES = { + AI_BOT: 'AiBot', + AI_THREAD: 'AiThread', +} as const; diff --git a/app/products/agents/constants/plugin.ts b/app/products/agents/constants/plugin.ts new file mode 100644 index 000000000..e935dc773 --- /dev/null +++ b/app/products/agents/constants/plugin.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const AGENTS_PLUGIN_ID = 'mattermost-ai'; diff --git a/app/products/agents/constants/screens.ts b/app/products/agents/constants/screens.ts index 492e2a883..45cefee26 100644 --- a/app/products/agents/constants/screens.ts +++ b/app/products/agents/constants/screens.ts @@ -1,10 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +export const AGENT_CHAT = 'AgentChat'; +export const AGENT_THREADS_LIST = 'AgentThreadsList'; export const AGENTS_SELECTOR = 'AgentsSelector'; export const AGENTS_REWRITE_OPTIONS = 'AgentsRewriteOptions'; export default { + AGENT_CHAT, + AGENT_THREADS_LIST, AGENTS_SELECTOR, AGENTS_REWRITE_OPTIONS, } as const; diff --git a/app/products/agents/constants/version.ts b/app/products/agents/constants/version.ts new file mode 100644 index 000000000..6eccfb779 --- /dev/null +++ b/app/products/agents/constants/version.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Minimum version of the mattermost-plugin-agents required for mobile support +export const MINIMUM_MAJOR_VERSION = 1; +export const MINIMUM_MINOR_VERSION = 4; +export const MINIMUM_PATCH_VERSION = 0; diff --git a/app/products/agents/database/models/ai_bot.ts b/app/products/agents/database/models/ai_bot.ts new file mode 100644 index 000000000..49964fc73 --- /dev/null +++ b/app/products/agents/database/models/ai_bot.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {AGENTS_TABLES} from '@agents/constants/database'; +import {field, json} from '@nozbe/watermelondb/decorators'; +import Model, {type Associations} from '@nozbe/watermelondb/Model'; + +import {safeParseJSONStringArray} from '@utils/helpers'; + +import type {ChannelAccessLevel, UserAccessLevel} from '@agents/types'; +import type AiBotModelInterface from '@agents/types/database/models/ai_bot'; + +const {AI_BOT} = AGENTS_TABLES; + +/** + * The AiBot model represents an AI bot in the Mattermost app. + */ +export default class AiBotModel extends Model implements AiBotModelInterface { + /** table (name) : AiBot */ + static table = AI_BOT; + + /** associations : Describes every relationship to this table. */ + static associations: Associations = {}; + + /** display_name : The display name of the bot */ + @field('display_name') displayName!: string; + + /** username : The username of the bot */ + @field('username') username!: string; + + /** last_icon_update : Timestamp when the icon was last updated */ + @field('last_icon_update') lastIconUpdate!: number; + + /** dm_channel_id : The DM channel ID for the bot */ + @field('dm_channel_id') dmChannelId!: string; + + /** channel_access_level : The channel access level setting */ + @field('channel_access_level') channelAccessLevel!: ChannelAccessLevel; + + /** channel_ids : Array of channel IDs the bot has access to */ + @json('channel_ids', safeParseJSONStringArray) channelIds!: string[]; + + /** user_access_level : The user access level setting */ + @field('user_access_level') userAccessLevel!: UserAccessLevel; + + /** user_ids : Array of user IDs the bot has access to */ + @json('user_ids', safeParseJSONStringArray) userIds!: string[]; + + /** team_ids : Array of team IDs the bot belongs to */ + @json('team_ids', safeParseJSONStringArray) teamIds!: string[]; +} diff --git a/app/products/agents/database/models/ai_thread.ts b/app/products/agents/database/models/ai_thread.ts new file mode 100644 index 000000000..3215c2995 --- /dev/null +++ b/app/products/agents/database/models/ai_thread.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {AGENTS_TABLES} from '@agents/constants/database'; +import {field, immutableRelation} from '@nozbe/watermelondb/decorators'; +import Model, {type Associations} from '@nozbe/watermelondb/Model'; + +import {MM_TABLES} from '@constants/database'; + +import type AiThreadModelInterface from '@agents/types/database/models/ai_thread'; +import type {Relation} from '@nozbe/watermelondb'; +import type ChannelModel from '@typings/database/models/servers/channel'; + +const {AI_THREAD} = AGENTS_TABLES; +const {CHANNEL} = MM_TABLES.SERVER; + +/** + * The AiThread model represents an AI thread conversation in the Mattermost app. + */ +export default class AiThreadModel extends Model implements AiThreadModelInterface { + /** table (name) : AiThread */ + static table = AI_THREAD; + + /** associations : Describes every relationship to this table. */ + static associations: Associations = { + + /** A CHANNEL can be associated to AI_THREAD (relationship is 1:N) */ + [CHANNEL]: {type: 'belongs_to', key: 'channel_id'}, + }; + + /** message : The preview message text */ + @field('message') message!: string; + + /** title : The thread title */ + @field('title') title!: string; + + /** channel_id : Foreign key to the DM channel with the bot */ + @field('channel_id') channelId!: string; + + /** reply_count : Number of replies in the thread */ + @field('reply_count') replyCount!: number; + + /** update_at : Timestamp when the thread was last updated */ + @field('update_at') updateAt!: number; + + /** channel : The CHANNEL to which this AI_THREAD belongs */ + @immutableRelation(CHANNEL, 'channel_id') channel!: Relation; +} diff --git a/app/products/agents/database/models/index.ts b/app/products/agents/database/models/index.ts new file mode 100644 index 000000000..18983c08c --- /dev/null +++ b/app/products/agents/database/models/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {default as AiBotModel} from './ai_bot'; +export {default as AiThreadModel} from './ai_thread'; diff --git a/app/products/agents/database/operators/comparators/index.ts b/app/products/agents/database/operators/comparators/index.ts new file mode 100644 index 000000000..bb6188c18 --- /dev/null +++ b/app/products/agents/database/operators/comparators/index.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {LLMBot, AIThread} from '@agents/types'; +import type AiBotModel from '@agents/types/database/models/ai_bot'; +import type AiThreadModel from '@agents/types/database/models/ai_thread'; + +/** + * Determines if an AI bot record should be updated based on differences between existing and new data. + */ +export const shouldUpdateAiBotRecord = (existingRecord: AiBotModel, newRaw: LLMBot): boolean => { + // Check for any changes that would require an update + return ( + existingRecord.displayName !== newRaw.displayName || + existingRecord.username !== newRaw.username || + existingRecord.lastIconUpdate !== newRaw.lastIconUpdate || + existingRecord.dmChannelId !== newRaw.dmChannelID || + existingRecord.channelAccessLevel !== newRaw.channelAccessLevel || + existingRecord.userAccessLevel !== newRaw.userAccessLevel || + JSON.stringify(existingRecord.channelIds) !== JSON.stringify(newRaw.channelIDs) || + JSON.stringify(existingRecord.userIds) !== JSON.stringify(newRaw.userIDs) || + JSON.stringify(existingRecord.teamIds) !== JSON.stringify(newRaw.teamIDs) + ); +}; + +/** + * Determines if an AI thread record should be updated based on differences between existing and new data. + */ +export const shouldUpdateAiThreadRecord = (existingRecord: AiThreadModel, newRaw: AIThread): boolean => { + // Check for any changes that would require an update + return ( + existingRecord.message !== newRaw.message || + existingRecord.title !== newRaw.title || + existingRecord.channelId !== newRaw.channel_id || + existingRecord.replyCount !== newRaw.reply_count || + existingRecord.updateAt !== newRaw.update_at + ); +}; diff --git a/app/products/agents/database/operators/handlers/index.ts b/app/products/agents/database/operators/handlers/index.ts new file mode 100644 index 000000000..4a32a6d10 --- /dev/null +++ b/app/products/agents/database/operators/handlers/index.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {AGENTS_TABLES} from '@agents/constants/database'; +import {type Model} from '@nozbe/watermelondb'; + +import {getUniqueRawsBy} from '@database/operator/utils/general'; +import {logDebug} from '@utils/log'; + +import {shouldUpdateAiBotRecord, shouldUpdateAiThreadRecord} from '../comparators'; +import {transformAiBotRecord, transformAiThreadRecord} from '../transformers'; + +import type {LLMBot, AIThread} from '@agents/types'; +import type AiBotModel from '@agents/types/database/models/ai_bot'; +import type AiThreadModel from '@agents/types/database/models/ai_thread'; +import type ServerDataOperatorBase from '@database/operator/server_data_operator/handlers'; + +type HandleAIBotsArgs = { + prepareRecordsOnly: boolean; + bots?: LLMBot[]; +}; + +type HandleAIThreadsArgs = { + prepareRecordsOnly: boolean; + threads?: AIThread[]; +}; + +const {AI_BOT, AI_THREAD} = AGENTS_TABLES; + +export interface AgentsHandlerMix { + handleAIBots: (args: HandleAIBotsArgs) => Promise; + handleAIThreads: (args: HandleAIThreadsArgs) => Promise; +} + +const AgentsHandler = >(superclass: TBase) => class extends superclass { + /** + * Handles the AI bot records. + * @param {HandleAIBotsArgs} args - The arguments for handling AI bot records. + * @param {boolean} args.prepareRecordsOnly - If true, only prepares the records without saving them. + * @param {LLMBot[]} [args.bots] - The AI bot records to handle. + * @returns {Promise} - A promise that resolves to an array of handled AI bot records. + */ + handleAIBots = async ({bots, prepareRecordsOnly}: HandleAIBotsArgs): Promise => { + if (!bots?.length) { + logDebug('[AgentsHandler.handleAIBots] No bots to handle'); + return []; + } + + const batchRecords: Model[] = []; + const uniqueRaws = getUniqueRawsBy({raws: bots, key: 'id'}); + const incomingIds = new Set(uniqueRaws.map((raw) => raw.id)); + + const existingRecords = await this.database.collections.get(AI_BOT).query().fetch(); + const existingRecordsMap = new Map(existingRecords.map((record) => [record.id, record])); + + const createOrUpdateRaws = uniqueRaws.reduce((res, raw) => { + const existingRecord = existingRecordsMap.get(raw.id); + if (!existingRecord) { + res.push(raw); + } else if (shouldUpdateAiBotRecord(existingRecord, raw)) { + res.push(raw); + } + return res; + }, []); + + if (createOrUpdateRaws.length) { + const records = await this.handleRecords({ + fieldName: 'id', + tableName: AI_BOT, + prepareRecordsOnly: true, + createOrUpdateRawValues: createOrUpdateRaws, + transformer: transformAiBotRecord, + }, 'handleAIBots prepare'); + batchRecords.push(...records); + } + + for (const record of existingRecords) { + if (!incomingIds.has(record.id)) { + batchRecords.push(record.prepareDestroyPermanently()); + } + } + + if (batchRecords.length && !prepareRecordsOnly) { + await this.batchRecords(batchRecords, 'handleAIBots batch'); + } + + return batchRecords; + }; + + /** + * Handles the AI thread records. + * @param {HandleAIThreadsArgs} args - The arguments for handling AI thread records. + * @param {boolean} args.prepareRecordsOnly - If true, only prepares the records without saving them. + * @param {AIThread[]} [args.threads] - The AI thread records to handle. + * @returns {Promise} - A promise that resolves to an array of handled AI thread records. + */ + handleAIThreads = async ({threads, prepareRecordsOnly}: HandleAIThreadsArgs): Promise => { + if (!threads?.length) { + logDebug('[AgentsHandler.handleAIThreads] No threads to handle'); + return []; + } + + const batchRecords: Model[] = []; + const uniqueRaws = getUniqueRawsBy({raws: threads, key: 'id'}); + const incomingIds = new Set(uniqueRaws.map((raw) => raw.id)); + + const existingRecords = await this.database.collections.get(AI_THREAD).query().fetch(); + const existingRecordsMap = new Map(existingRecords.map((record) => [record.id, record])); + + const createOrUpdateRaws = uniqueRaws.reduce((res, raw) => { + const existingRecord = existingRecordsMap.get(raw.id); + if (!existingRecord) { + res.push(raw); + } else if (shouldUpdateAiThreadRecord(existingRecord, raw)) { + res.push(raw); + } + return res; + }, []); + + if (createOrUpdateRaws.length) { + const records = await this.handleRecords({ + fieldName: 'id', + tableName: AI_THREAD, + prepareRecordsOnly: true, + createOrUpdateRawValues: createOrUpdateRaws, + transformer: transformAiThreadRecord, + }, 'handleAIThreads prepare'); + batchRecords.push(...records); + } + + for (const record of existingRecords) { + if (!incomingIds.has(record.id)) { + batchRecords.push(record.prepareDestroyPermanently()); + } + } + + if (batchRecords.length && !prepareRecordsOnly) { + await this.batchRecords(batchRecords, 'handleAIThreads batch'); + } + + return batchRecords; + }; +}; + +export default AgentsHandler; diff --git a/app/products/agents/database/operators/transformers/index.ts b/app/products/agents/database/operators/transformers/index.ts new file mode 100644 index 000000000..c833e24c9 --- /dev/null +++ b/app/products/agents/database/operators/transformers/index.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {AGENTS_TABLES} from '@agents/constants/database'; + +import {OperationType} from '@constants/database'; +import {prepareBaseRecord} from '@database/operator/server_data_operator/transformers/index'; + +import type {LLMBot, AIThread} from '@agents/types'; +import type AiBotModel from '@agents/types/database/models/ai_bot'; +import type AiThreadModel from '@agents/types/database/models/ai_thread'; +import type {TransformerArgs} from '@typings/database/database'; + +const {AI_BOT, AI_THREAD} = AGENTS_TABLES; + +/** + * transformAiBotRecord: Prepares a record of the SERVER database 'AiBot' table for update or create actions. + */ +export const transformAiBotRecord = ({action, database, value}: TransformerArgs): Promise => { + const raw = value.raw; + const record = value.record; + const isCreateAction = action === OperationType.CREATE; + + if (!isCreateAction && !record) { + return Promise.reject(new Error('Record not found for non create action')); + } + + const fieldsMapper = (bot: AiBotModel) => { + bot._raw.id = isCreateAction ? (raw?.id ?? bot.id) : bot.id; + bot.displayName = raw.displayName ?? record?.displayName ?? ''; + bot.username = raw.username ?? record?.username ?? ''; + bot.lastIconUpdate = raw.lastIconUpdate ?? record?.lastIconUpdate ?? 0; + bot.dmChannelId = raw.dmChannelID ?? record?.dmChannelId ?? ''; + bot.channelAccessLevel = raw.channelAccessLevel ?? record?.channelAccessLevel ?? 0; + bot.channelIds = raw.channelIDs ?? record?.channelIds ?? []; + bot.userAccessLevel = raw.userAccessLevel ?? record?.userAccessLevel ?? 0; + bot.userIds = raw.userIDs ?? record?.userIds ?? []; + bot.teamIds = raw.teamIDs ?? record?.teamIds ?? []; + }; + + return prepareBaseRecord({ + action, + database, + tableName: AI_BOT, + value, + fieldsMapper, + }); +}; + +/** + * transformAiThreadRecord: Prepares a record of the SERVER database 'AiThread' table for update or create actions. + */ +export const transformAiThreadRecord = ({action, database, value}: TransformerArgs): Promise => { + const raw = value.raw; + const record = value.record; + const isCreateAction = action === OperationType.CREATE; + + if (!isCreateAction && !record) { + return Promise.reject(new Error('Record not found for non create action')); + } + + const fieldsMapper = (thread: AiThreadModel) => { + thread._raw.id = isCreateAction ? (raw?.id ?? thread.id) : thread.id; + thread.message = raw.message ?? record?.message ?? ''; + thread.title = raw.title ?? record?.title ?? ''; + thread.channelId = raw.channel_id ?? record?.channelId ?? ''; + thread.replyCount = raw.reply_count ?? record?.replyCount ?? 0; + thread.updateAt = raw.update_at ?? record?.updateAt ?? 0; + }; + + return prepareBaseRecord({ + action, + database, + tableName: AI_THREAD, + value, + fieldsMapper, + }); +}; diff --git a/app/products/agents/database/queries/bot.ts b/app/products/agents/database/queries/bot.ts new file mode 100644 index 000000000..7176eaaa7 --- /dev/null +++ b/app/products/agents/database/queries/bot.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {AGENTS_TABLES} from '@agents/constants/database'; +import {Q, type Database} from '@nozbe/watermelondb'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import type AiBotModel from '@agents/types/database/models/ai_bot'; + +const {AI_BOT} = AGENTS_TABLES; + +/** + * Returns a query for all AI bots in the database. + */ +export function queryAIBots(database: Database) { + return database.get(AI_BOT).query(); +} + +/** + * Returns an observable for all AI bots in the database. + */ +export function observeAIBots(database: Database) { + return queryAIBots(database).observe(); +} + +/** + * Returns a query for an AI bot by ID. + */ +export function queryAIBotById(database: Database, botId: string) { + return database.get(AI_BOT).query( + Q.where('id', botId), + Q.take(1), + ); +} + +/** + * Returns an observable for an AI bot by ID. + */ +export function observeAIBotById(database: Database, botId: string) { + return queryAIBotById(database, botId).observe().pipe( + switchMap((bots) => { + return bots.length ? bots[0].observe() : of$(undefined); + }), + ); +} + +/** + * Gets an AI bot by ID from the database. + */ +export async function getAIBotById(database: Database, botId: string) { + try { + const bot = await database.get(AI_BOT).find(botId); + return bot; + } catch { + return undefined; + } +} + +/** + * Gets all AI bots from the database. + */ +export async function getAllAIBots(database: Database) { + return queryAIBots(database).fetch(); +} diff --git a/app/products/agents/database/queries/thread.ts b/app/products/agents/database/queries/thread.ts new file mode 100644 index 000000000..52cd4f872 --- /dev/null +++ b/app/products/agents/database/queries/thread.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {AGENTS_TABLES} from '@agents/constants/database'; +import {Q, type Database} from '@nozbe/watermelondb'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import type AiThreadModel from '@agents/types/database/models/ai_thread'; + +const {AI_THREAD} = AGENTS_TABLES; + +/** + * Returns a query for all AI threads sorted by update_at descending (most recent first). + */ +export function queryAIThreads(database: Database) { + return database.get(AI_THREAD).query( + Q.sortBy('update_at', Q.desc), + ); +} + +/** + * Returns an observable for all AI threads sorted by update_at descending. + */ +export function observeAIThreads(database: Database) { + return queryAIThreads(database).observeWithColumns(['update_at', 'reply_count', 'message', 'title']); +} + +/** + * Returns a query for AI threads by channel ID. + */ +export function queryAIThreadsByChannelId(database: Database, channelId: string) { + return database.get(AI_THREAD).query( + Q.where('channel_id', channelId), + Q.sortBy('update_at', Q.desc), + ); +} + +/** + * Returns an observable for AI threads by channel ID. + */ +export function observeAIThreadsByChannelId(database: Database, channelId: string) { + return queryAIThreadsByChannelId(database, channelId).observeWithColumns(['update_at', 'reply_count']); +} + +/** + * Returns a query for an AI thread by ID. + */ +export function queryAIThreadById(database: Database, threadId: string) { + return database.get(AI_THREAD).query( + Q.where('id', threadId), + Q.take(1), + ); +} + +/** + * Returns an observable for an AI thread by ID. + */ +export function observeAIThreadById(database: Database, threadId: string) { + return queryAIThreadById(database, threadId).observe().pipe( + switchMap((threads) => { + return threads.length ? threads[0].observe() : of$(undefined); + }), + ); +} + +/** + * Gets an AI thread by ID from the database. + */ +export async function getAIThreadById(database: Database, threadId: string) { + try { + const thread = await database.get(AI_THREAD).find(threadId); + return thread; + } catch { + return undefined; + } +} + +/** + * Gets all AI threads from the database. + */ +export async function getAllAIThreads(database: Database) { + return queryAIThreads(database).fetch(); +} diff --git a/app/products/agents/database/queries/version.test.ts b/app/products/agents/database/queries/version.test.ts new file mode 100644 index 000000000..ac489d58a --- /dev/null +++ b/app/products/agents/database/queries/version.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MINIMUM_MAJOR_VERSION, MINIMUM_MINOR_VERSION, MINIMUM_PATCH_VERSION} from '@agents/constants/version'; + +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; + +import {fetchIsAgentsEnabled, observeIsAgentsEnabled} from './version'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; + +const MINIMUM_VERSION = `${MINIMUM_MAJOR_VERSION}.${MINIMUM_MINOR_VERSION}.${MINIMUM_PATCH_VERSION}`; + +describe('Agents Version Queries', () => { + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init(['agents-version.test.com']); + operator = DatabaseManager.serverDatabases['agents-version.test.com']!.operator; + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase('agents-version.test.com'); + }); + + describe('observeIsAgentsEnabled', () => { + it('should return false when no agents version is set', async () => { + const subscriptionNext = jest.fn(); + const result = observeIsAgentsEnabled(operator.database); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + + it(`should return true when agents version meets minimum requirements (${MINIMUM_VERSION})`, async () => { + const subscriptionNext = jest.fn(); + const result = observeIsAgentsEnabled(operator.database); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.AGENTS_VERSION, value: MINIMUM_VERSION}], + prepareRecordsOnly: false, + }); + + expect(subscriptionNext).toHaveBeenCalledWith(true); + }); + + it('should return true when agents version has higher major version', async () => { + const subscriptionNext = jest.fn(); + const higherVersion = `${MINIMUM_MAJOR_VERSION + 1}.0.0`; + const result = observeIsAgentsEnabled(operator.database); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.AGENTS_VERSION, value: higherVersion}], + prepareRecordsOnly: false, + }); + + expect(subscriptionNext).toHaveBeenCalledWith(true); + }); + + it('should handle empty version string', async () => { + const subscriptionNext = jest.fn(); + const result = observeIsAgentsEnabled(operator.database); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.AGENTS_VERSION, value: ''}], + prepareRecordsOnly: false, + }); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + + it('should react to version changes', async () => { + const subscriptionNext = jest.fn(); + const result = observeIsAgentsEnabled(operator.database); + result.subscribe({next: subscriptionNext}); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + subscriptionNext.mockClear(); + + const aboveVersion = `${MINIMUM_MAJOR_VERSION + 1}.${MINIMUM_MINOR_VERSION + 1}.${MINIMUM_PATCH_VERSION + 1}`; + const belowVersion = `${MINIMUM_MAJOR_VERSION - 1}.${MINIMUM_MINOR_VERSION}.${MINIMUM_PATCH_VERSION}`; + + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.AGENTS_VERSION, value: aboveVersion}], + prepareRecordsOnly: false, + }); + + expect(subscriptionNext).toHaveBeenCalledWith(true); + subscriptionNext.mockClear(); + + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.AGENTS_VERSION, value: belowVersion}], + prepareRecordsOnly: false, + }); + + expect(subscriptionNext).toHaveBeenCalledWith(false); + }); + }); + + describe('fetchIsAgentsEnabled', () => { + it('should return false when no agents version is set', async () => { + const result = await fetchIsAgentsEnabled(operator.database); + expect(result).toBe(false); + }); + + it(`should return true when agents version meets minimum requirements (${MINIMUM_VERSION})`, async () => { + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.AGENTS_VERSION, value: MINIMUM_VERSION}], + prepareRecordsOnly: false, + }); + + const result = await fetchIsAgentsEnabled(operator.database); + expect(result).toBe(true); + }); + + it('should return true when agents version has higher major version', async () => { + const higherVersion = `${MINIMUM_MAJOR_VERSION + 1}.0.0`; + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.AGENTS_VERSION, value: higherVersion}], + prepareRecordsOnly: false, + }); + + const result = await fetchIsAgentsEnabled(operator.database); + expect(result).toBe(true); + }); + + it('should handle empty version string', async () => { + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.AGENTS_VERSION, value: ''}], + prepareRecordsOnly: false, + }); + + const result = await fetchIsAgentsEnabled(operator.database); + expect(result).toBe(false); + }); + + it('should return false when agents version is below minimum', async () => { + const belowVersion = `${MINIMUM_MAJOR_VERSION - 1}.${MINIMUM_MINOR_VERSION}.${MINIMUM_PATCH_VERSION}`; + await operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.AGENTS_VERSION, value: belowVersion}], + prepareRecordsOnly: false, + }); + + const result = await fetchIsAgentsEnabled(operator.database); + expect(result).toBe(false); + }); + }); +}); diff --git a/app/products/agents/database/queries/version.ts b/app/products/agents/database/queries/version.ts new file mode 100644 index 000000000..8f7f383aa --- /dev/null +++ b/app/products/agents/database/queries/version.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MINIMUM_MAJOR_VERSION, MINIMUM_MINOR_VERSION, MINIMUM_PATCH_VERSION} from '@agents/constants/version'; +import {Q, type Database} from '@nozbe/watermelondb'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {SYSTEM_IDENTIFIERS, MM_TABLES} from '@constants/database'; +import {isMinimumServerVersion} from '@utils/helpers'; + +import type SystemModel from '@typings/database/models/servers/system'; + +function queryAgentsVersion(database: Database) { + return database.get(MM_TABLES.SERVER.SYSTEM).query( + Q.where('id', SYSTEM_IDENTIFIERS.AGENTS_VERSION), + ); +} + +function isAgentsEnabledFromSystemModel(systems: SystemModel[]) { + const version = systems[0]?.value; + if (!version) { + return false; + } + + return isMinimumServerVersion(version, MINIMUM_MAJOR_VERSION, MINIMUM_MINOR_VERSION, MINIMUM_PATCH_VERSION); +} + +export async function fetchIsAgentsEnabled(database: Database) { + const systems = await queryAgentsVersion(database).fetch(); + return isAgentsEnabledFromSystemModel(systems); +} + +export async function fetchAgentsVersion(database: Database): Promise { + const systems = await queryAgentsVersion(database).fetch(); + return systems[0]?.value || ''; +} + +export function observeIsAgentsEnabled(database: Database) { + return database.get(MM_TABLES.SERVER.SYSTEM).query( + Q.where('id', SYSTEM_IDENTIFIERS.AGENTS_VERSION), + ).observeWithColumns(['value']).pipe( + switchMap((systems) => { + return of$(isAgentsEnabledFromSystemModel(systems)); + }), + ); +} diff --git a/app/products/agents/database/schema/ai_bot.ts b/app/products/agents/database/schema/ai_bot.ts new file mode 100644 index 000000000..7c228b36c --- /dev/null +++ b/app/products/agents/database/schema/ai_bot.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {AGENTS_TABLES} from '@agents/constants/database'; +import {tableSchema} from '@nozbe/watermelondb'; + +const {AI_BOT} = AGENTS_TABLES; + +export default tableSchema({ + name: AI_BOT, + columns: [ + {name: 'display_name', type: 'string'}, + {name: 'username', type: 'string'}, + {name: 'last_icon_update', type: 'number'}, + {name: 'dm_channel_id', type: 'string', isIndexed: true}, + {name: 'channel_access_level', type: 'number'}, + {name: 'channel_ids', type: 'string'}, // JSON string array + {name: 'user_access_level', type: 'number'}, + {name: 'user_ids', type: 'string'}, // JSON string array + {name: 'team_ids', type: 'string'}, // JSON string array + ], +}); diff --git a/app/products/agents/database/schema/ai_thread.ts b/app/products/agents/database/schema/ai_thread.ts new file mode 100644 index 000000000..c4afecf44 --- /dev/null +++ b/app/products/agents/database/schema/ai_thread.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {AGENTS_TABLES} from '@agents/constants/database'; +import {tableSchema} from '@nozbe/watermelondb'; + +const {AI_THREAD} = AGENTS_TABLES; + +export default tableSchema({ + name: AI_THREAD, + columns: [ + {name: 'message', type: 'string'}, + {name: 'title', type: 'string'}, + {name: 'channel_id', type: 'string', isIndexed: true}, + {name: 'reply_count', type: 'number'}, + {name: 'update_at', type: 'number', isIndexed: true}, + ], +}); diff --git a/app/products/agents/database/schema/index.ts b/app/products/agents/database/schema/index.ts new file mode 100644 index 000000000..7bd6d0ad3 --- /dev/null +++ b/app/products/agents/database/schema/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {default as AiBotSchema} from './ai_bot'; +export {default as AiThreadSchema} from './ai_thread'; diff --git a/app/products/agents/screens/agent_chat/agent_chat.test.tsx b/app/products/agents/screens/agent_chat/agent_chat.test.tsx new file mode 100644 index 000000000..0c0ffbb9d --- /dev/null +++ b/app/products/agents/screens/agent_chat/agent_chat.test.tsx @@ -0,0 +1,181 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {waitFor} from '@testing-library/react-native'; +import React from 'react'; + +import {createDirectChannel} from '@actions/remote/channel'; +import {Screens} from '@constants'; +import NetworkManager from '@managers/network_manager'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import AgentChat from './agent_chat'; + +import type AiBotModel from '@agents/types/database/models/ai_bot'; +import type {Database} from '@nozbe/watermelondb'; + +const SERVER_URL = 'https://test-server.com'; + +// --- Context-enforcing mock for @gorhom/portal --- +// Mirrors the real library: Portal throws without PortalProvider ancestor. +// PortalProvider sets a flag so we can assert it was rendered — Autocomplete's +// Portal is conditional (focused + Android) so the context check alone isn't +// enough to catch a missing PortalProvider in all test runs. +// We must mock because the package isn't in transformIgnorePatterns. +let portalProviderRendered = false; +jest.mock('@gorhom/portal', () => { + const ReactMock = require('react'); + const {View} = require('react-native'); + const PortalContext = ReactMock.createContext(null); + return { + PortalProvider: ({children}: {children: React.ReactNode}) => { + portalProviderRendered = true; + return {children}; + }, + Portal: ({children}: {children: React.ReactNode}) => { + const ctx = ReactMock.useContext(PortalContext); + if (!ctx) { + throw new Error("'PortalDispatchContext' cannot be null, please add 'PortalProvider' to the root component."); + } + return {children}; + }, + }; +}); + +// --- Context mocks --- +jest.mock('@context/server', () => ({ + useServerUrl: jest.fn(() => SERVER_URL), + withServerUrl: (Component: React.ComponentType) => (props: any) => ( + + ), +})); + +// --- Network / action mocks --- +jest.mock('@agents/actions/remote/bots', () => ({ + fetchAIBots: jest.fn(() => Promise.resolve({})), +})); + +jest.mock('@actions/remote/channel', () => ({ + createDirectChannel: jest.fn(() => Promise.resolve({data: {id: 'placeholder'}})), + getChannelTimezones: jest.fn(() => Promise.resolve({channelTimezones: []})), +})); + +jest.mock('@actions/remote/thread', () => ({ + fetchAndSwitchToThread: jest.fn(() => Promise.resolve({})), +})); + +jest.mock('@actions/remote/file', () => ({ + buildAbsoluteUrl: jest.fn(() => 'https://test-server.com/avatar.png'), +})); + +jest.mock('@actions/remote/user', () => ({ + buildProfileImageUrl: jest.fn(() => '/api/v4/users/bot-123/image'), +})); + +// --- Navigation mocks --- +jest.mock('@screens/navigation', () => ({ + popTopScreen: jest.fn(), + bottomSheet: jest.fn(), + dismissBottomSheet: jest.fn(), + openAsBottomSheet: jest.fn(), +})); + +jest.mock('@agents/screens/navigation', () => ({ + goToAgentThreadsList: jest.fn(), +})); + +// --- Native module mocks --- +jest.mock('@hooks/android_back_handler', () => jest.fn()); + +jest.mock('@hooks/utils', () => ({ + usePreventDoubleTap: (fn: Function) => fn, +})); + +jest.mock('@utils/post', () => ({ + persistentNotificationsConfirmation: jest.fn(), +})); + +// --- UI mocks (SVG can't render in Jest) --- +jest.mock('@agents/components/illustrations', () => { + const {View} = require('react-native'); + return {AgentsIntro: () => }; +}); + +const mockBot = { + id: 'bot-123', + displayName: 'Test Agent', + username: 'testagent', + lastIconUpdate: 0, + dmChannelId: 'dm-channel-123', + channelAccessLevel: 0, + channelIds: [], + userAccessLevel: 0, + userIds: [], + teamIds: [], +} as unknown as AiBotModel; + +describe('AgentChat', () => { + let database: Database; + + beforeEach(async () => { + const server = await TestHelper.setupServerDatabase(SERVER_URL); + database = server.database; + + // Use the fully-set-up channel from TestHelper (has channel info, + // membership, etc.) so PostDraft's deep observable chain works. + const channelId = TestHelper.basicChannel!.id; + (createDirectChannel as jest.Mock).mockResolvedValue({data: {id: channelId}}); + }); + + afterEach(async () => { + await TestHelper.tearDown(); + NetworkManager.invalidateClient(SERVER_URL); + }); + + beforeEach(() => { + portalProviderRendered = false; + }); + + it('should render PostDraft without error when channel is available', async () => { + const {getByTestId} = renderWithEverything( + , + {database}, + ); + + // Wait for async channel creation (createDirectChannel in useEffect) + // then PostDraft mounts inside the full provider tree. + // This exercises the real PostDraft → DraftHandler → SendHandler chain. + // If any required provider (e.g. PortalProvider, DatabaseProvider, + // ExtraKeyboardProvider) is missing or breaks, this test fails. + await waitFor(() => { + expect(getByTestId('agent_chat.post_draft')).toBeTruthy(); + }); + + // PortalProvider must wrap PostDraft so that Autocomplete's Portal + // (used on Android when input is focused) has a context to render into. + expect(portalProviderRendered).toBe(true); + }); + + it('should render intro screen when no bots are available', async () => { + const {getByTestId, queryByTestId} = renderWithEverything( + , + {database}, + ); + + // Wait for loading state to resolve (fetchAIBots is async) + await waitFor(() => { + expect(getByTestId('mock-agents-intro')).toBeTruthy(); + }); + expect(queryByTestId('agent_chat.post_draft')).toBeNull(); + }); +}); diff --git a/app/products/agents/screens/agent_chat/agent_chat.tsx b/app/products/agents/screens/agent_chat/agent_chat.tsx new file mode 100644 index 000000000..b8e32a1e1 --- /dev/null +++ b/app/products/agents/screens/agent_chat/agent_chat.tsx @@ -0,0 +1,414 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fetchAIBots} from '@agents/actions/remote/bots'; +import {AgentsIntro} from '@agents/components/illustrations'; +import BotSelectorItem from '@agents/screens/agent_chat/bot_selector_item'; +import {goToAgentThreadsList} from '@agents/screens/navigation'; +import {PortalProvider} from '@gorhom/portal'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {type LayoutChangeEvent, Pressable, Text, View} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {createDirectChannel} from '@actions/remote/channel'; +import {buildAbsoluteUrl} from '@actions/remote/file'; +import {fetchAndSwitchToThread} from '@actions/remote/thread'; +import {buildProfileImageUrl} from '@actions/remote/user'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import Loading from '@components/loading'; +import PostDraft from '@components/post_draft'; +import {ITEM_HEIGHT} from '@components/slide_up_panel_item'; +import {Screens} from '@constants'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import {usePreventDoubleTap} from '@hooks/utils'; +import {TITLE_HEIGHT} from '@screens/bottom_sheet/content'; +import {bottomSheet, dismissBottomSheet, popTopScreen} from '@screens/navigation'; +import {bottomSheetSnapPoint} from '@utils/helpers'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type AiBotModel from '@agents/types/database/models/ai_bot'; +import type {AvailableScreens} from '@typings/screens/navigation'; + +type Props = { + componentId: AvailableScreens; + bots: AiBotModel[]; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flex: 1, + backgroundColor: theme.sidebarBg, + }, + headerContainer: { + backgroundColor: theme.sidebarBg, + }, + headerContent: { + flexDirection: 'row', + alignItems: 'center', + height: 52, + paddingHorizontal: 8, + }, + headerLeft: { + width: 100, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + }, + headerIconButton: { + padding: 10, + }, + headerCenter: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + color: theme.sidebarText, + ...typography('Heading', 300), + }, + headerSubtitle: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + }, + headerSubtitleText: { + color: changeOpacity(theme.sidebarText, 0.72), + ...typography('Body', 75), + }, + headerRight: { + width: 100, + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + paddingHorizontal: 8, + gap: 4, + }, + mainContent: { + flex: 1, + backgroundColor: theme.centerChannelBg, + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + overflow: 'hidden', + }, + content: { + flex: 1, + justifyContent: 'flex-end', + }, + introContent: { + gap: 8, + paddingHorizontal: 24, + paddingTop: 16, + paddingBottom: 32, + }, + welcomeText: { + color: theme.centerChannelColor, + ...typography('Heading', 600), + }, + descriptionText: { + color: theme.centerChannelColor, + ...typography('Body', 200), + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + }, + errorText: { + color: theme.errorTextColor, + textAlign: 'center', + marginTop: 16, + ...typography('Body', 100), + }, +})); + +const AgentChat = ({ + componentId, + bots, +}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const serverUrl = useServerUrl(); + const insets = useSafeAreaInsets(); + const styles = getStyleSheet(theme); + + // Track if this is the first load + const initialLoadDone = useRef(false); + const [selectedBot, setSelectedBot] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [channelId, setChannelId] = useState(null); + const [containerHeight, setContainerHeight] = useState(0); + + // Auto-select first bot when bots are loaded from database + useEffect(() => { + if (bots.length > 0 && !selectedBot) { + setSelectedBot(bots[0]); + } + }, [bots, selectedBot]); + + // Refresh bots from network on mount + useEffect(() => { + const refreshBots = async () => { + // If we have cached data, don't show loading spinner + if (bots.length > 0) { + initialLoadDone.current = true; + setLoading(false); + } + + const {error: fetchError} = await fetchAIBots(serverUrl); + + // Mark initial load as done + if (!initialLoadDone.current) { + initialLoadDone.current = true; + setLoading(false); + } + + // Only show error if fetch failed AND we have no cached data + if (fetchError && bots.length === 0) { + setError(intl.formatMessage({ + id: 'agents.chat.error_loading_bots', + defaultMessage: 'Failed to load agents. Please try again.', + })); + } + }; + + refreshBots(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps -- only run on mount + + // Show error if no bots after loading + useEffect(() => { + if (!loading && bots.length === 0 && !error) { + setError(intl.formatMessage({ + id: 'agents.chat.no_bots', + defaultMessage: 'No agents available.', + })); + } else if (bots.length > 0 && error) { + // Clear error if we now have bots + setError(null); + } + }, [loading, bots.length, error, intl]); + + // Get or create DM channel when bot is selected + useEffect(() => { + const getChannel = async () => { + if (!selectedBot) { + setChannelId(null); + return; + } + + const {data, error: channelError} = await createDirectChannel( + serverUrl, + selectedBot.id, + ); + + if (channelError || !data) { + setError(intl.formatMessage({ + id: 'agents.chat.error_creating_channel', + defaultMessage: 'Failed to start conversation. Please try again.', + })); + return; + } + + setChannelId(data.id); + }; + + getChannel(); + }, [selectedBot, serverUrl, intl]); + + const exit = useCallback(() => { + popTopScreen(componentId); + }, [componentId]); + + useAndroidHardwareBackHandler(componentId, exit); + + const handleHistoryPress = useCallback(() => { + goToAgentThreadsList(intl); + }, [intl]); + + const handleBotSelect = useCallback((bot: AiBotModel) => { + setSelectedBot(bot); + dismissBottomSheet(); + }, []); + + const handleBotSelectorPress = usePreventDoubleTap(useCallback(() => { + if (bots.length <= 1) { + return; + } + + const renderContent = () => { + return ( + <> + {bots.map((bot) => { + const avatarUrl = buildAbsoluteUrl( + serverUrl, + buildProfileImageUrl(serverUrl, bot.id, bot.lastIconUpdate), + ); + + return ( + + ); + })} + + ); + }; + + const snapPoint = bottomSheetSnapPoint(bots.length, ITEM_HEIGHT); + bottomSheet({ + closeButtonId: 'close-bot-selector', + renderContent, + snapPoints: [1, (snapPoint + TITLE_HEIGHT)], + title: intl.formatMessage({ + id: 'agents.chat.select_agent', + defaultMessage: 'Select an agent', + }), + theme, + }); + }, [bots, selectedBot, theme, intl, handleBotSelect, serverUrl])); + + const onLayout = useCallback((e: LayoutChangeEvent) => { + setContainerHeight(e.nativeEvent.layout.height); + }, []); + + const handlePostCreated = useCallback((postId: string) => { + fetchAndSwitchToThread(serverUrl, postId); + }, [serverUrl]); + + // Show loading only on first load with no cached data + if (loading && bots.length === 0) { + return ( + + + + ); + } + + return ( + + {/* Header */} + + + {/* Left - Back button */} + + [styles.headerIconButton, pressed && {opacity: 0.72}]} + testID='agent_chat.back_button' + > + + + + + {/* Center - Title and bot selector */} + [styles.headerCenter, pressed && bots.length > 1 && {opacity: 0.72}]} + disabled={bots.length <= 1} + testID='agent_chat.bot_selector' + > + + + {selectedBot ? ( + + {selectedBot.displayName} + + ) : ( + + )} + {bots.length > 1 && ( + + )} + + + + {/* Right - History icon */} + + [styles.headerIconButton, pressed && {opacity: 0.72}]} + testID='agent_chat.history_button' + > + + + + + + + {/* Main content */} + + + + + + + + {error && {error}} + + + + {channelId && ( + + )} + + + + ); +}; + +export default AgentChat; diff --git a/app/products/agents/screens/agent_chat/bot_selector_item.test.tsx b/app/products/agents/screens/agent_chat/bot_selector_item.test.tsx new file mode 100644 index 000000000..4a11e744c --- /dev/null +++ b/app/products/agents/screens/agent_chat/bot_selector_item.test.tsx @@ -0,0 +1,121 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent, render} from '@testing-library/react-native'; +import React, {type ComponentProps} from 'react'; + +import {Preferences} from '@constants'; + +import BotSelectorItem from './bot_selector_item'; + +import type AiBotModel from '@agents/types/database/models/ai_bot'; + +// Mock SlideUpPanelItem to avoid complex dependencies +jest.mock('@components/slide_up_panel_item', () => { + const {Text, TouchableOpacity, View} = require('react-native'); + const MockSlideUpPanelItem = ({ + testID, + text, + onPress, + leftIcon, + rightIcon, + }: { + testID: string; + text: string; + onPress: () => void; + leftIcon: string | {uri: string}; + rightIcon?: string; + }) => ( + + {text} + + {typeof leftIcon === 'string' ? leftIcon : leftIcon.uri} + + {rightIcon && ( + + {rightIcon} + + )} + + ); + return MockSlideUpPanelItem; +}); + +describe('BotSelectorItem', () => { + // Mock bot data with camelCase properties matching AiBotModel + const mockBot = { + id: 'bot-123', + displayName: 'Test Bot', + username: 'testbot', + lastIconUpdate: 0, + dmChannelId: 'dm-channel-123', + channelAccessLevel: 0, + channelIds: [], + userAccessLevel: 0, + userIds: [], + teamIds: [], + } as unknown as AiBotModel; + + const getBaseProps = (): ComponentProps => ({ + bot: mockBot, + isSelected: false, + onSelect: jest.fn(), + theme: Preferences.THEMES.denim, + }); + + it('should render bot display name', () => { + const props = getBaseProps(); + const {getByText} = render(); + + expect(getByText('Test Bot')).toBeTruthy(); + }); + + it('should call onSelect with bot when pressed', () => { + const props = getBaseProps(); + const {getByTestId} = render(); + + fireEvent.press(getByTestId('agent_chat.bot_selector.bot_item.bot-123')); + expect(props.onSelect).toHaveBeenCalledWith(mockBot); + }); + + it('should show check icon when selected', () => { + const props = getBaseProps(); + props.isSelected = true; + const {getByTestId} = render(); + + // Right icon should be rendered when selected + expect(getByTestId('agent_chat.bot_selector.bot_item.bot-123.rightIcon')).toBeTruthy(); + }); + + it('should not show check icon when not selected', () => { + const props = getBaseProps(); + props.isSelected = false; + const {queryByTestId} = render(); + + // Right icon should not be rendered when not selected + expect(queryByTestId('agent_chat.bot_selector.bot_item.bot-123.rightIcon')).toBeNull(); + }); + + it('should use avatar url when provided', () => { + const props = getBaseProps(); + props.avatarUrl = 'https://example.com/avatar.png'; + const {getByText} = render(); + + // Avatar URL should be passed to leftIcon + expect(getByText('https://example.com/avatar.png')).toBeTruthy(); + }); + + it('should use fallback icon when no avatar url', () => { + const props = getBaseProps(); + + // avatarUrl is undefined + const {getByText} = render(); + + // Fallback icon name should be used + expect(getByText('account-outline')).toBeTruthy(); + }); + +}); diff --git a/app/products/agents/screens/agent_chat/bot_selector_item.tsx b/app/products/agents/screens/agent_chat/bot_selector_item.tsx new file mode 100644 index 000000000..9eefb6fde --- /dev/null +++ b/app/products/agents/screens/agent_chat/bot_selector_item.tsx @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; + +import SlideUpPanelItem from '@components/slide_up_panel_item'; + +import type AiBotModel from '@agents/types/database/models/ai_bot'; + +type Props = { + bot: AiBotModel; + avatarUrl?: string; + isSelected: boolean; + onSelect: (bot: AiBotModel) => void; + theme: Theme; +}; + +const BotSelectorItem = ({bot, avatarUrl, isSelected, onSelect, theme}: Props) => { + const handlePress = useCallback(() => { + onSelect(bot); + }, [bot, onSelect]); + + return ( + + ); +}; + +export default BotSelectorItem; diff --git a/app/products/agents/screens/agent_chat/index.ts b/app/products/agents/screens/agent_chat/index.ts new file mode 100644 index 000000000..60951c76a --- /dev/null +++ b/app/products/agents/screens/agent_chat/index.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {observeAIBots} from '@agents/database/queries/bot'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; + +import AgentChat from './agent_chat'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + return { + bots: observeAIBots(database), + }; +}); + +export default withDatabase(enhanced(AgentChat)); diff --git a/app/products/agents/screens/agent_threads_list/agent_threads_list.tsx b/app/products/agents/screens/agent_threads_list/agent_threads_list.tsx new file mode 100644 index 000000000..ed15ac6ad --- /dev/null +++ b/app/products/agents/screens/agent_threads_list/agent_threads_list.tsx @@ -0,0 +1,343 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fetchAIBots} from '@agents/actions/remote/bots'; +import {fetchAIThreads} from '@agents/actions/remote/threads'; +import ThreadItem, {THREAD_ITEM_HEIGHT} from '@agents/screens/agent_threads_list/thread_item'; +import {goToAgentChat} from '@agents/screens/navigation'; +import {FlashList, type ListRenderItem} from '@shopify/flash-list'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {View, Text, Pressable, RefreshControl} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {fetchAndSwitchToThread} from '@actions/remote/thread'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import Loading from '@components/loading'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import {popTopScreen} from '@screens/navigation'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type AiBotModel from '@agents/types/database/models/ai_bot'; +import type AiThreadModel from '@agents/types/database/models/ai_thread'; +import type {AvailableScreens} from '@typings/screens/navigation'; + +type Props = { + componentId: AvailableScreens; + threads: AiThreadModel[]; + bots: AiBotModel[]; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flex: 1, + backgroundColor: theme.sidebarBg, + }, + headerContainer: { + backgroundColor: theme.sidebarBg, + }, + headerContent: { + flexDirection: 'row', + alignItems: 'center', + height: 52, + paddingHorizontal: 8, + }, + headerLeft: { + width: 100, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + }, + headerIconButton: { + padding: 10, + }, + headerCenter: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + color: theme.sidebarText, + ...typography('Heading', 300), + }, + headerRight: { + width: 100, + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + paddingHorizontal: 8, + }, + mainContent: { + flex: 1, + backgroundColor: theme.centerChannelBg, + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + overflow: 'hidden', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 40, + paddingTop: 120, + }, + emptyIcon: { + marginBottom: 16, + }, + emptyTitle: { + color: theme.centerChannelColor, + marginBottom: 8, + textAlign: 'center', + ...typography('Body', 400, 'SemiBold'), + }, + emptyDescription: { + color: changeOpacity(theme.centerChannelColor, 0.64), + textAlign: 'center', + ...typography('Body', 100), + }, + errorText: { + color: theme.errorTextColor, + textAlign: 'center', + ...typography('Body', 100), + }, + listContent: { + paddingVertical: 0, + }, +})); + +const AgentThreadsList = ({ + componentId, + threads, + bots, +}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const serverUrl = useServerUrl(); + const insets = useSafeAreaInsets(); + const styles = getStyleSheet(theme); + + // Track if this is the first load (show loading spinner only on first load with no cached data) + const initialLoadDone = useRef(false); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + // Create a map of channel_id to bot display name + const botNameByChannelId = useMemo(() => { + const map: Record = {}; + for (const bot of bots) { + if (bot.dmChannelId) { + map[bot.dmChannelId] = bot.displayName; + } + } + return map; + }, [bots]); + + // Refresh data from network (updates database, observers will update UI) + const refreshData = useCallback(async (isRefresh = false) => { + if (isRefresh) { + setRefreshing(true); + } + + // Fetch both bots and threads in parallel + const [botsResult, threadsResult] = await Promise.all([ + fetchAIBots(serverUrl), + fetchAIThreads(serverUrl), + ]); + + if (isRefresh) { + setRefreshing(false); + } + + // Only show error if both calls failed and we have no cached data + if (botsResult.error && threadsResult.error && threads.length === 0) { + setError(intl.formatMessage({ + id: 'agents.threads_list.error_loading', + defaultMessage: 'Failed to load conversations. Please try again.', + })); + } else { + setError(null); + } + + // Mark initial load as done + if (!initialLoadDone.current) { + initialLoadDone.current = true; + setLoading(false); + } + }, [serverUrl, intl, threads.length]); + + // On mount, refresh data from network + useEffect(() => { + // If we have cached data, don't show loading spinner + if (threads.length > 0 || bots.length > 0) { + initialLoadDone.current = true; + setLoading(false); + } + refreshData(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps -- only run on mount + + const exit = useCallback(() => { + popTopScreen(componentId); + }, [componentId]); + + useAndroidHardwareBackHandler(componentId, exit); + + const handleRefresh = useCallback(() => { + refreshData(true); + }, [refreshData]); + + const handleNewChat = useCallback(() => { + goToAgentChat(intl); + }, [intl]); + + const handleThreadPress = useCallback(async (thread: AiThreadModel) => { + // Navigate to the thread + await fetchAndSwitchToThread(serverUrl, thread.id, false); + + // fetchAndSwitchToThread handles navigation, so we don't need to exit + }, [serverUrl]); + + const renderItem: ListRenderItem = useCallback(({item}) => { + return ( + + ); + }, [botNameByChannelId, handleThreadPress, theme]); + + const renderEmptyState = useCallback(() => { + if (error) { + return ( + + + + {error} + + ); + } + + return ( + + + + + + ); + }, [error, styles, theme]); + + // Show loading only on first load with no cached data + if (loading && threads.length === 0) { + return ( + + ); + } + + return ( + + {/* Header */} + + + {/* Left - Back button */} + + [styles.headerIconButton, pressed && {opacity: 0.72}]} + testID='agent_threads_list.back_button' + > + + + + + {/* Center - Title */} + + + + + {/* Right - New chat button */} + + [styles.headerIconButton, pressed && {opacity: 0.72}]} + testID='agent_threads_list.new_chat_button' + > + + + + + + + {/* Main content */} + + + } + testID='agent_threads_list.flat_list' + /> + + + ); +}; + +export default AgentThreadsList; diff --git a/app/products/agents/screens/agent_threads_list/index.ts b/app/products/agents/screens/agent_threads_list/index.ts new file mode 100644 index 000000000..5e9352ec1 --- /dev/null +++ b/app/products/agents/screens/agent_threads_list/index.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {observeAIBots} from '@agents/database/queries/bot'; +import {observeAIThreads} from '@agents/database/queries/thread'; +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; + +import AgentThreadsList from './agent_threads_list'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + return { + threads: observeAIThreads(database), + bots: observeAIBots(database), + }; +}); + +export default withDatabase(enhanced(AgentThreadsList)); diff --git a/app/products/agents/screens/agent_threads_list/thread_item.test.tsx b/app/products/agents/screens/agent_threads_list/thread_item.test.tsx new file mode 100644 index 000000000..9a5cb3edf --- /dev/null +++ b/app/products/agents/screens/agent_threads_list/thread_item.test.tsx @@ -0,0 +1,118 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {type ComponentProps} from 'react'; + +import {Preferences} from '@constants'; +import {fireEvent, renderWithIntlAndTheme} from '@test/intl-test-helper'; + +import ThreadItem from './thread_item'; + +import type AiThreadModel from '@agents/types/database/models/ai_thread'; + +describe('ThreadItem', () => { + // Mock thread data with camelCase properties matching AiThreadModel + const mockThread = { + id: 'thread-123', + channelId: 'channel-456', + title: 'Test Conversation', + message: 'This is a preview of the conversation', + replyCount: 5, + updateAt: Date.now() - 60000, // 1 minute ago + } as unknown as unknown as AiThreadModel; + + const getBaseProps = (): ComponentProps => ({ + thread: mockThread, + onPress: jest.fn(), + theme: Preferences.THEMES.denim, + }); + + it('should render thread title', () => { + const props = getBaseProps(); + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Test Conversation')).toBeTruthy(); + }); + + it('should render default title when thread has no title', () => { + const props = getBaseProps(); + props.thread = {...mockThread, title: ''} as unknown as AiThreadModel; + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Conversation with Agents')).toBeTruthy(); + }); + + it('should render message preview when present', () => { + const props = getBaseProps(); + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('This is a preview of the conversation')).toBeTruthy(); + }); + + it('should not render message preview when empty', () => { + const props = getBaseProps(); + props.thread = {...mockThread, message: ''} as unknown as AiThreadModel; + const {queryByText} = renderWithIntlAndTheme(); + + // Message should not be present + expect(queryByText('This is a preview of the conversation')).toBeNull(); + }); + + it('should call onPress with thread when pressed', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntlAndTheme(); + + fireEvent.press(getByTestId('agent_thread.thread-123')); + expect(props.onPress).toHaveBeenCalledWith(mockThread); + }); + + it('should render plural "replies" for multiple replies', () => { + const props = getBaseProps(); + props.thread = {...mockThread, replyCount: 5} as unknown as AiThreadModel; + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('5 replies')).toBeTruthy(); + }); + + it('should render singular "reply" for one reply', () => { + const props = getBaseProps(); + props.thread = {...mockThread, replyCount: 1} as unknown as AiThreadModel; + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('1 reply')).toBeTruthy(); + }); + + it('should render zero replies', () => { + const props = getBaseProps(); + props.thread = {...mockThread, replyCount: 0} as unknown as AiThreadModel; + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('0 replies')).toBeTruthy(); + }); + + it('should render bot name tag when provided', () => { + const props = getBaseProps(); + props.botName = 'AI Assistant'; + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('AI Assistant')).toBeTruthy(); + }); + + it('should not render bot name tag when not provided', () => { + const props = getBaseProps(); + + // botName is undefined + const {queryByText} = renderWithIntlAndTheme(); + + // No bot tag should be rendered + expect(queryByText('AI Assistant')).toBeNull(); + }); + + it('should render with correct testID', () => { + const props = getBaseProps(); + props.thread = {...mockThread, id: 'unique-thread-id'} as unknown as AiThreadModel; + const {getByTestId} = renderWithIntlAndTheme(); + + expect(getByTestId('agent_thread.unique-thread-id')).toBeTruthy(); + }); +}); diff --git a/app/products/agents/screens/agent_threads_list/thread_item.tsx b/app/products/agents/screens/agent_threads_list/thread_item.tsx new file mode 100644 index 000000000..4920c2db9 --- /dev/null +++ b/app/products/agents/screens/agent_threads_list/thread_item.tsx @@ -0,0 +1,145 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {View, Text, Pressable} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import FormattedRelativeTime from '@components/formatted_relative_time'; +import FormattedText from '@components/formatted_text'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type AiThreadModel from '@agents/types/database/models/ai_thread'; + +export const THREAD_ITEM_HEIGHT = 88; + +type Props = { + thread: AiThreadModel; + onPress: (thread: AiThreadModel) => void; + botName?: string; + theme: Theme; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + threadItem: { + flexDirection: 'row', + paddingLeft: 26, + paddingRight: 20, + paddingVertical: 16, + backgroundColor: theme.centerChannelBg, + borderBottomWidth: 1, + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.08), + }, + threadContent: { + flex: 1, + gap: 6, + }, + threadHeader: { + flexDirection: 'row', + alignItems: 'center', + }, + threadTitle: { + flex: 1, + color: theme.centerChannelColor, + ...typography('Body', 200, 'SemiBold'), + }, + threadTimestamp: { + color: changeOpacity(theme.centerChannelColor, 0.64), + marginLeft: 8, + ...typography('Body', 50), + }, + threadPreview: { + color: theme.centerChannelColor, + ...typography('Body', 200), + }, + threadMeta: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + threadReplyCount: { + color: changeOpacity(theme.centerChannelColor, 0.64), + paddingHorizontal: 8, + paddingVertical: 4.5, + ...typography('Body', 75, 'SemiBold'), + }, + agentTag: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + borderRadius: 4, + paddingHorizontal: 4, + paddingVertical: 2, + }, + agentTagText: { + color: theme.centerChannelColor, + textTransform: 'uppercase', + letterSpacing: 0.2, + ...typography('Body', 25, 'SemiBold'), + }, +})); + +const ThreadItem = ({thread, onPress, botName, theme}: Props) => { + const intl = useIntl(); + const styles = getStyleSheet(theme); + + const handlePress = useCallback(() => { + onPress(thread); + }, [thread, onPress]); + + return ( + [styles.threadItem, pressed && {opacity: 0.72}]} + testID={`agent_thread.${thread.id}`} + > + + + + {thread.title || intl.formatMessage({ + id: 'agents.threads_list.default_title', + defaultMessage: 'Conversation with Agents', + })} + + + + {thread.message && ( + + {thread.message} + + )} + + + + {botName && ( + + + {botName} + + + )} + + + + ); +}; + +export default ThreadItem; diff --git a/app/products/agents/screens/index.ts b/app/products/agents/screens/index.tsx similarity index 71% rename from app/products/agents/screens/index.ts rename to app/products/agents/screens/index.tsx index d8701f222..6c4793c02 100644 --- a/app/products/agents/screens/index.ts +++ b/app/products/agents/screens/index.tsx @@ -7,6 +7,10 @@ import {withServerDatabase} from '@database/components'; export function loadAgentsScreen(screenName: string | number) { switch (screenName) { + case Screens.AGENT_CHAT: + return withServerDatabase(require('@agents/screens/agent_chat').default); + case Screens.AGENT_THREADS_LIST: + return withServerDatabase(require('@agents/screens/agent_threads_list').default); case Screens.AGENTS_SELECTOR: return withServerDatabase(require('@agents/screens/agent_selector').default); case Screens.AGENTS_REWRITE_OPTIONS: diff --git a/app/products/agents/screens/navigation.ts b/app/products/agents/screens/navigation.ts new file mode 100644 index 000000000..938742a9c --- /dev/null +++ b/app/products/agents/screens/navigation.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import AgentScreens from '@agents/constants/screens'; + +import {goToScreen} from '@screens/navigation'; + +import type {IntlShape} from 'react-intl'; + +const hideTopBarOptions = { + topBar: { + visible: false, + height: 0, + }, +}; + +export function goToAgentChat(intl: IntlShape) { + const title = intl.formatMessage({id: 'agents.chat.title', defaultMessage: 'Agents'}); + goToScreen(AgentScreens.AGENT_CHAT, title, {}, hideTopBarOptions); +} + +export function goToAgentThreadsList(intl: IntlShape) { + const title = intl.formatMessage({id: 'agents.threads_list.title', defaultMessage: 'Agent chat history'}); + goToScreen(AgentScreens.AGENT_THREADS_LIST, title, {}, hideTopBarOptions); +} diff --git a/app/products/agents/screens/rewrite_options/index.tsx b/app/products/agents/screens/rewrite_options/index.tsx index 0f6401fea..87d9c242a 100644 --- a/app/products/agents/screens/rewrite_options/index.tsx +++ b/app/products/agents/screens/rewrite_options/index.tsx @@ -20,6 +20,7 @@ import {dismissBottomSheet, openAsBottomSheet} from '@screens/navigation'; import {bottomSheetSnapPoint} from '@utils/helpers'; import {logWarning} from '@utils/log'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; import type {Agent, RewriteAction} from '@agents/types'; import type {AvailableScreens} from '@typings/screens/navigation'; @@ -122,12 +123,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, customPromptInput: { flex: 1, - fontSize: 16, - lineHeight: 24, color: theme.centerChannelColor, - padding: 0, - margin: 0, includeFontPadding: false, + ...typography('Body', 200), textAlignVertical: 'center', verticalAlign: 'middle', justifyContent: 'center', diff --git a/app/products/agents/types/database/models/ai_bot.ts b/app/products/agents/types/database/models/ai_bot.ts new file mode 100644 index 000000000..a9ad3c002 --- /dev/null +++ b/app/products/agents/types/database/models/ai_bot.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ChannelAccessLevel, UserAccessLevel} from '@agents/types'; +import type {Model} from '@nozbe/watermelondb'; +import type {Associations} from '@nozbe/watermelondb/Model'; + +/** + * The AiBot model represents an AI bot in the Mattermost app. + */ +declare class AiBotModel extends Model { + /** table (name) : AiBot */ + static table: string; + + /** associations : Describes every relationship to this table. */ + static associations: Associations; + + /** The display name of the bot */ + displayName: string; + + /** The username of the bot */ + username: string; + + /** Timestamp when the icon was last updated */ + lastIconUpdate: number; + + /** The DM channel ID for the bot */ + dmChannelId: string; + + /** The channel access level setting */ + channelAccessLevel: ChannelAccessLevel; + + /** Array of channel IDs the bot has access to */ + channelIds: string[]; + + /** The user access level setting */ + userAccessLevel: UserAccessLevel; + + /** Array of user IDs the bot has access to */ + userIds: string[]; + + /** Array of team IDs the bot belongs to */ + teamIds: string[]; +} + +export default AiBotModel; diff --git a/app/products/agents/types/database/models/ai_thread.ts b/app/products/agents/types/database/models/ai_thread.ts new file mode 100644 index 000000000..b574664de --- /dev/null +++ b/app/products/agents/types/database/models/ai_thread.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Model, Relation} from '@nozbe/watermelondb'; +import type {Associations} from '@nozbe/watermelondb/Model'; +import type ChannelModel from '@typings/database/models/servers/channel'; + +/** + * The AiThread model represents an AI thread conversation in the Mattermost app. + */ +declare class AiThreadModel extends Model { + /** table (name) : AiThread */ + static table: string; + + /** associations : Describes every relationship to this table. */ + static associations: Associations; + + /** The preview message text */ + message: string; + + /** The thread title */ + title: string; + + /** Foreign key to the DM channel with the bot */ + channelId: string; + + /** Number of replies in the thread */ + replyCount: number; + + /** Timestamp when the thread was last updated */ + updateAt: number; + + /** The CHANNEL to which this AI_THREAD belongs */ + channel: Relation; +} + +export default AiThreadModel; diff --git a/app/products/agents/types/index.ts b/app/products/agents/types/index.ts index c9c41bba9..ea8c3a987 100644 --- a/app/products/agents/types/index.ts +++ b/app/products/agents/types/index.ts @@ -78,6 +78,67 @@ export interface StreamingState { annotations: Annotation[]; // Citations/annotations for the post } +/** + * AI thread data structure from the server + */ +export interface AIThread { + id: string; // Post ID + message: string; // Preview text + title: string; // Thread title + channel_id: string; // DM channel with bot + reply_count: number; // Number of replies + update_at: number; // Last update timestamp +} + +/** + * Channel access level values + */ +export const ChannelAccessLevel = { + All: 0, + Allow: 1, + Block: 2, +} as const; + +// eslint-disable-next-line @typescript-eslint/no-redeclare -- TypeScript supports same-name type/value pairs as enum alternative +export type ChannelAccessLevel = typeof ChannelAccessLevel[keyof typeof ChannelAccessLevel]; + +/** + * User access level values + */ +export const UserAccessLevel = { + All: 0, + Allow: 1, + Block: 2, +} as const; + +// eslint-disable-next-line @typescript-eslint/no-redeclare -- TypeScript supports same-name type/value pairs as enum alternative +export type UserAccessLevel = typeof UserAccessLevel[keyof typeof UserAccessLevel]; + +/** + * LLM Bot data structure + */ +export interface LLMBot { + id: string; + displayName: string; + username: string; + lastIconUpdate: number; + dmChannelID: string; + channelAccessLevel: ChannelAccessLevel; + channelIDs: string[]; + userAccessLevel: UserAccessLevel; + userIDs: string[]; + teamIDs: string[]; +} + +/** + * AI Bots response from the server + */ +export interface AIBotsResponse { + bots: LLMBot[]; + searchEnabled: boolean; + allowUnsafeLinks: boolean; +} + // ============================================================================ // Rewrite Types // ============================================================================ diff --git a/app/screens/home/channel_list/categories_list/categories_list.tsx b/app/screens/home/channel_list/categories_list/categories_list.tsx index 31c9c1189..e8dc9f72b 100644 --- a/app/screens/home/channel_list/categories_list/categories_list.tsx +++ b/app/screens/home/channel_list/categories_list/categories_list.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import AgentsButton from '@agents/components/agents_button'; import React, {useEffect, useMemo, useState} from 'react'; import {DeviceEventEmitter, useWindowDimensions} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; @@ -8,7 +9,7 @@ import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-nati import DraftsButton from '@components/drafts_buttton'; import ThreadsButton from '@components/threads_button'; import {Events, Screens} from '@constants'; -import {CHANNEL, DRAFT, THREAD} from '@constants/screens'; +import {AGENTS, CHANNEL, DRAFT, THREAD} from '@constants/screens'; import {TABLET_SIDEBAR_WIDTH, TEAM_SIDEBAR_WIDTH} from '@constants/view'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; @@ -38,6 +39,7 @@ type ChannelListProps = { scheduledPostHasError: boolean; lastChannelId?: string; scheduledPostsEnabled?: boolean; + agentsEnabled?: boolean; showPlaybooksButton?: boolean; }; @@ -45,7 +47,7 @@ const getTabletWidth = (moreThanOneTeam: boolean) => { return TABLET_SIDEBAR_WIDTH - (moreThanOneTeam ? TEAM_SIDEBAR_WIDTH : 0); }; -type ScreenType = typeof DRAFT | typeof THREAD | typeof CHANNEL; +type ScreenType = typeof AGENTS | typeof DRAFT | typeof THREAD | typeof CHANNEL; const CategoriesList = ({ hasChannels, @@ -57,6 +59,7 @@ const CategoriesList = ({ scheduledPostHasError, lastChannelId, scheduledPostsEnabled, + agentsEnabled, showPlaybooksButton, }: ChannelListProps) => { const theme = useTheme(); @@ -77,7 +80,7 @@ const CategoriesList = ({ useEffect(() => { const listener = DeviceEventEmitter.addListener(Events.ACTIVE_SCREEN, (screen: string) => { - if (screen === DRAFT || screen === THREAD) { + if (screen === AGENTS || screen === DRAFT || screen === THREAD) { setActiveScreen(screen); } else { setActiveScreen(CHANNEL); @@ -127,6 +130,18 @@ const CategoriesList = ({ return null; }, [activeScreen, draftsCount, isTablet, scheduledPostCount, scheduledPostHasError, scheduledPostsEnabled]); + const agentsButtonComponent = useMemo(() => { + if (!agentsEnabled) { + return null; + } + + return ( + + ); + }, [agentsEnabled, activeScreen]); + const playbooksButtonComponent = useMemo(() => { if (!showPlaybooksButton) { return null; @@ -147,11 +162,12 @@ const CategoriesList = ({ {threadButtonComponent} {draftsButtonComponent} + {agentsButtonComponent} {playbooksButtonComponent} ); - }, [draftsButtonComponent, hasChannels, isTablet, playbooksButtonComponent, threadButtonComponent]); + }, [agentsButtonComponent, draftsButtonComponent, hasChannels, isTablet, playbooksButtonComponent, threadButtonComponent]); return ( diff --git a/app/screens/home/channel_list/categories_list/index.tsx b/app/screens/home/channel_list/categories_list/index.tsx index cf893a709..8149000c8 100644 --- a/app/screens/home/channel_list/categories_list/index.tsx +++ b/app/screens/home/channel_list/categories_list/index.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {observeIsAgentsEnabled} from '@agents/database/queries/version'; import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {combineLatest, of} from 'rxjs'; import {switchMap} from 'rxjs/operators'; @@ -29,6 +30,7 @@ const enchanced = withObservables([], ({database}: WithDatabaseArgs) => { switchMap((scheduledPosts) => of(hasScheduledPostError(scheduledPosts))), ); const scheduledPostsEnabled = observeScheduledPostEnabled(database); + const agentsEnabled = observeIsAgentsEnabled(database); const showPlaybooksButton = currentTeamId.pipe( switchMap((teamId) => combineLatest([ observeIsPlaybooksEnabled(database), @@ -43,6 +45,7 @@ const enchanced = withObservables([], ({database}: WithDatabaseArgs) => { scheduledPostCount, scheduledPostHasError, scheduledPostsEnabled, + agentsEnabled, showPlaybooksButton, }; }); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index b1ae6829a..7cd52e0bf 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -28,10 +28,18 @@ "agents.channel_summary.since_placeholder": "Select date", "agents.channel_summary.until": "End", "agents.channel_summary.until_placeholder": "Select date", + "agents.chat.error_creating_channel": "Failed to start conversation. Please try again.", + "agents.chat.error_loading_bots": "Failed to load agents. Please try again.", + "agents.chat.intro_description": "Agents are here to help.", + "agents.chat.intro_title": "Ask Agents anything", + "agents.chat.no_bots": "No agents available.", + "agents.chat.select_agent": "Select an agent", + "agents.chat.title": "Agents", "agents.citations.title": "Sources ({count})", "agents.controls.regenerate": "Regenerate", "agents.controls.stop": "Stop Generating", "agents.generating": "Generating response...", + "agents.home_button.title": "Agents", "agents.reasoning.thinking": "Thinking", "agents.regenerate.cancel": "Cancel", "agents.regenerate.confirm": "Regenerate", @@ -39,6 +47,13 @@ "agents.regenerate.confirm_title": "Regenerate Response", "agents.selector.no_agents": "No agents available", "agents.selector.title": "Select Agent", + "agents.threads_list.default_title": "Conversation with Agents", + "agents.threads_list.empty_description": "Your conversations with agents will appear here. Start a new conversation to get started.", + "agents.threads_list.empty_title": "No conversations yet", + "agents.threads_list.error_loading": "Failed to load conversations. Please try again.", + "agents.threads_list.error_title": "Unable to load conversations", + "agents.threads_list.reply_count": "{count, plural, one {# reply} other {# replies}}", + "agents.threads_list.title": "Agent chat history", "agents.tool_call.approval_warning": "Approving lets Agents use this response in their next message. That message will be visible to everyone in the channel — only approve results you are comfortable sharing.", "agents.tool_call.approve": "Accept", "agents.tool_call.keep_private": "Keep private", diff --git a/docs/database/server/server.md b/docs/database/server/server.md index ef089da01..a7acb1d06 100644 --- a/docs/database/server/server.md +++ b/docs/database/server/server.md @@ -1,4 +1,4 @@ -# Server Database - Schema Version 18 +# Server Database - Schema Version 19 # Please bump the version by 1, any time the schema changes. # Also, include the migration plan under app/database/migration/server, # update all models, relationships and types. @@ -422,3 +422,27 @@ status string timezone string update_at number username string + + +AiBot +- +id PK string +display_name string +username string +last_icon_update number +dm_channel_id string INDEX +channel_access_level number +channel_ids string +user_access_level number +user_ids string +team_ids string + + +AiThread +- +id PK string +message string +title string +channel_id string INDEX FK >- Channel.id +reply_count number +update_at number INDEX