Agents "RHS" view (#9318)

* Add agents RHS functionality for mobile

Implements a dedicated agents interface accessible from the home screen, featuring:
- Agent chat screen with bot selector and message input
- Thread list screen showing all agent conversations
- Remote actions for fetching bots and threads from plugin API
- Navigation between chat, threads, and individual conversations
- Integration with plus menu for easy access

Follows similar pattern to playbooks with modal screens and navigation helpers.

* Fix icon name for agents menu item

Change robot-happy-outline to robot-happy to resolve PropType validation error.

* Add agents button to sidebar and fix agent chat issues

- Add AgentsButton component to channel list sidebar below Drafts
- Fix thread navigation to use fetchAndSwitchToThread instead of switchToChannelById
- Replace custom TextInput with PostDraft component in agent_chat
- Fix icon name from message-reply-text-outline to reply-outline
- Add missing i18n translations for agents UI

* Implement automatic navigation to thread after sending agent message

* Implement bot selector bottom sheet with avatars. Replace click-and-cycle behavior with a proper bottom sheet menu showing all available agents with their profile pictures. Add bot avatar display to the dropdown button for better visual identification.

* Top bar design modifications.

* Add intro graphic and text

* Fix autocomplete not working

* Tweak styles and icons.

* Remove unused

* Add version/enabled check

* Remove excessive agent button

* Style fixes

* Use non-blocking then() for onPostCreated callback in send message hook

* Add unit tests for agents product components and actions

* Review feedback, offline support

* I18n

* Tests

* Address PR review feedback: deletion handling, UI fixes, schema docs

* Remove unnecessary deleteNotPresent flag from agents handlers

* Fix agents archived channel, empty data guard, and stale relative time

* Add smoke test for AgentChat and extend ToolCard test coverage

* Fix test quality issues: remove useless tests, strengthen assertions

* Address PR review: remove barrel file, add version comment

* Mock reanimated in CitationsList test to fix CI failure

* Fix CitationsList tests after always-mounted animation change

* Address PR review: typography, Pressable, FormattedText, schema bump, and cleanup

* Apply CLAUDE.md patterns across agents codebase: Pressable, typography, FormattedText, logDebug

* Fix schema test to expect version 19
This commit is contained in:
Christopher Speller 2026-03-16 09:48:32 -07:00 committed by GitHub
parent 6f0d538dd7
commit 6000e69886
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
87 changed files with 4606 additions and 121 deletions

View file

@ -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. **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**: **Database directory**:
- iOS: App Group directory (shared with extensions) - iOS: App Group directory (shared with extensions)
- Android: `${documentDirectory}/databases/` - Android: `${documentDirectory}/databases/`
@ -79,6 +85,7 @@ npm run clean # Clean build artifacts
### Actions Pattern ### Actions Pattern
- **Local Actions** (`app/actions/local/`): Database-only operations - **Local Actions** (`app/actions/local/`): Database-only operations
- **Remote Actions** (`app/actions/remote/`): Fetch from API → use operators to persist → return `{error}` on failure - **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
**Query layer** (`app/queries/`) provides both: **Query layer** (`app/queries/`) provides both:
@ -254,7 +261,8 @@ Located at `libraries/@mattermost/`:
### React Native & UI ### React Native & UI
- Don't create hooks inside render functions - extract as local components - 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 - Non-memoized inline styles add render stress - define in stylesheet instead
- **`StyleSheet.create` is unnecessary** when using `makeStyleSheetFromTheme` - just return the plain object - **`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 - **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 - Test components with long strings to ensure proper text handling
- Use constants from `PREFERENCES.THEMES` instead of hardcoded colors - 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) - **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 `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 `useServerUrl()` hook** instead of passing `serverUrl` as a prop - it's available via context
- **Use existing components**: Check for `<Loading>` instead of `<ActivityIndicator>`, `safeParseJSON()` instead of try/catch JSON.parse - **Use existing components**: Check for `<Loading>` instead of `<ActivityIndicator>`, `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 && <ItemList items={items} />}`) - **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 && <ItemList items={items} />}`)
- **Use `@utils/url` utilities**: `tryOpenURL()` instead of `Linking.openURL()`, `getUrlDomain()` with `urlParse` instead of `new URL()` - **Use `@utils/url` utilities**: `tryOpenURL()` instead of `Linking.openURL()`, `getUrlDomain()` with `urlParse` instead of `new URL()`
- **Use `FormattedText`** instead of `<Text>{intl.formatMessage(...)}</Text>` 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 ### Code Quality & Linting
@ -290,6 +301,7 @@ Located at `libraries/@mattermost/`:
- Use `logError()` instead of `console.error()` or `console.log()` - Use `logError()` instead of `console.error()` or `console.log()`
- Use `logDebug()` for debug-level information - Use `logDebug()` for debug-level information
- Don't ignore potential errors silently - handle them or add intentional comments - 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 - Don't log sensitive information
- **Add function/class prefix to logs**: e.g., `logError('[ClassName.methodName]', error)` to make debugging easier - **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 ### Performance
- Create parsers/expensive objects only once, not on every render (use refs or useMemo) - Create parsers/expensive objects only once, not on every render (use refs or useMemo)
- Memoize AST output to avoid regenerating on every render - 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) ### Localization (i18n)
- **CRITICAL**: Only update `en.json` - never modify other language files or Weblate gets corrupted - **CRITICAL**: Only update `en.json` - never modify other language files or Weblate gets corrupted

View file

@ -64,7 +64,7 @@ type AuthorsRequest = {
error?: unknown; error?: unknown;
} }
export async function createPost(serverUrl: string, post: Partial<Post>, files: FileInfo[] = []): Promise<{data?: boolean; error?: unknown}> { export async function createPost(serverUrl: string, post: Partial<Post>, files: FileInfo[] = []): Promise<{data?: boolean; post?: Post; error?: unknown}> {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) { if (!operator) {
return {error: `${serverUrl} database not found`}; return {error: `${serverUrl} database not found`};
@ -205,7 +205,7 @@ export async function createPost(serverUrl: string, post: Partial<Post>, files:
newPost = created; newPost = created;
return {data: true}; return {data: true, post: created};
} }
export const retryFailedPost = async (serverUrl: string, post: PostModel) => { export const retryFailedPost = async (serverUrl: string, post: PostModel) => {

View file

@ -3,6 +3,7 @@
import {checkIsAgentsPluginEnabled} from '@agents/actions/remote/agents_status'; import {checkIsAgentsPluginEnabled} from '@agents/actions/remote/agents_status';
import {handleAgentPostUpdate} from '@agents/actions/websocket'; import {handleAgentPostUpdate} from '@agents/actions/websocket';
import {handleAgentsEvents} from '@agents/actions/websocket/events';
import * as bookmark from '@actions/local/channel_bookmark'; import * as bookmark from '@actions/local/channel_bookmark';
import { import {
@ -346,4 +347,5 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
break; break;
} }
handlePlaybookEvents(serverUrl, msg); handlePlaybookEvents(serverUrl, msg);
handleAgentsEvents(serverUrl, msg);
} }

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {checkIsAgentsPluginEnabled} from '@agents/actions/remote/agents_status'; import {checkIsAgentsPluginEnabled} from '@agents/actions/remote/agents_status';
import {handleAgentsReconnect} from '@agents/actions/websocket/reconnect';
import {markChannelAsViewed} from '@actions/local/channel'; import {markChannelAsViewed} from '@actions/local/channel';
import {dataRetentionCleanup, expiredBoRPostCleanup} from '@actions/local/systems'; import {dataRetentionCleanup, expiredBoRPostCleanup} from '@actions/local/systems';
@ -92,6 +93,7 @@ async function doReconnect(serverUrl: string, groupLabel?: BaseRequestGroupLabel
const config = await getConfig(database); const config = await getConfig(database);
handlePlaybookReconnect(serverUrl); handlePlaybookReconnect(serverUrl);
handleAgentsReconnect(serverUrl);
if (isSupportedServerCalls(config?.Version)) { if (isSupportedServerCalls(config?.Version)) {
loadConfigAndCalls(serverUrl, currentUserId, groupLabel); loadConfigAndCalls(serverUrl, currentUserId, groupLabel);

View file

@ -24,6 +24,11 @@ const FormattedRelativeTime = ({timezone, value, updateIntervalInSeconds, ...pro
}; };
const [formattedTime, setFormattedTime] = useState(getFormattedRelativeTime); 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(() => { useEffect(() => {
if (updateIntervalInSeconds) { if (updateIntervalInSeconds) {
const interval = setInterval( const interval = setInterval(

View file

@ -30,6 +30,7 @@ type Props = {
updateValue: React.Dispatch<React.SetStateAction<string>>; updateValue: React.Dispatch<React.SetStateAction<string>>;
value: string; value: string;
setIsFocused: (isFocused: boolean) => void; setIsFocused: (isFocused: boolean) => void;
onPostCreated?: (postId: string) => void;
location?: AvailableScreens; location?: AvailableScreens;
} }
@ -51,6 +52,7 @@ export default function DraftHandler(props: Props) {
updateValue, updateValue,
value, value,
setIsFocused, setIsFocused,
onPostCreated,
location, location,
} = props; } = props;
@ -118,11 +120,7 @@ export default function DraftHandler(props: Props) {
uploadErrorHandlers.current[file.clientId!] = DraftEditPostUploadManager.registerErrorHandler(file.clientId!, newUploadError); uploadErrorHandlers.current[file.clientId!] = DraftEditPostUploadManager.registerErrorHandler(file.clientId!, newUploadError);
} }
} }
}, [files, 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]);
return ( return (
<SendHandler <SendHandler
@ -142,6 +140,7 @@ export default function DraftHandler(props: Props) {
updatePostInputTop={updatePostInputTop} updatePostInputTop={updatePostInputTop}
updateValue={updateValue} updateValue={updateValue}
setIsFocused={setIsFocused} setIsFocused={setIsFocused}
onPostCreated={onPostCreated}
location={location} location={location}
/> />
); );

View file

@ -30,6 +30,7 @@ type Props = {
isChannelScreen: boolean; isChannelScreen: boolean;
canShowPostPriority?: boolean; canShowPostPriority?: boolean;
location: AvailableScreens; location: AvailableScreens;
onPostCreated?: (postId: string) => void;
} }
function PostDraft({ function PostDraft({
@ -47,6 +48,7 @@ function PostDraft({
isChannelScreen, isChannelScreen,
canShowPostPriority, canShowPostPriority,
location, location,
onPostCreated,
}: Props) { }: Props) {
const [value, setValue] = useState(message); const [value, setValue] = useState(message);
const [cursorPosition, setCursorPosition] = useState(message.length); const [cursorPosition, setCursorPosition] = useState(message.length);
@ -100,6 +102,7 @@ function PostDraft({
updateValue={setValue} updateValue={setValue}
value={value} value={value}
setIsFocused={setIsFocused} setIsFocused={setIsFocused}
onPostCreated={onPostCreated}
location={location} location={location}
/> />
); );

View file

@ -53,6 +53,7 @@ type Props = {
channelDisplayName?: string; channelDisplayName?: string;
isFromDraftView?: boolean; isFromDraftView?: boolean;
draftReceiverUserName?: string; draftReceiverUserName?: string;
onPostCreated?: (postId: string) => void;
location?: AvailableScreens; location?: AvailableScreens;
} }
@ -95,6 +96,7 @@ export default function SendHandler({
isFromDraftView, isFromDraftView,
draftType, draftType,
postId, postId,
onPostCreated,
postBoRConfig, postBoRConfig,
location, location,
}: Props) { }: Props) {
@ -123,6 +125,7 @@ export default function SendHandler({
channelType, channelType,
postPriority, postPriority,
clearDraft, clearDraft,
onPostCreated,
postBoRConfig, postBoRConfig,
}); });

View file

@ -77,6 +77,7 @@ export const SYSTEM_IDENTIFIERS = {
TEAM_HISTORY: 'teamHistory', TEAM_HISTORY: 'teamHistory',
WEBSOCKET: 'WebSocket', WEBSOCKET: 'WebSocket',
PLAYBOOKS_VERSION: 'playbooks_version', PLAYBOOKS_VERSION: 'playbooks_version',
AGENTS_VERSION: 'agents_version',
LAST_BOR_POST_CLEANUP_RUN: 'lastBoRPostCleanupRun', LAST_BOR_POST_CLEANUP_RUN: 'lastBoRPostCleanupRun',
}; };

View file

@ -7,6 +7,7 @@ import PLAYBOOKS_SCREENS from '@playbooks/constants/screens';
export const ABOUT = 'About'; export const ABOUT = 'About';
export const ACCOUNT = 'Account'; export const ACCOUNT = 'Account';
export const AGENTS = 'Agents';
export const APPS_FORM = 'AppForm'; export const APPS_FORM = 'AppForm';
export const ATTACHMENT_OPTIONS = 'AttachmentOptions'; export const ATTACHMENT_OPTIONS = 'AttachmentOptions';
export const BOTTOM_SHEET = 'BottomSheet'; export const BOTTOM_SHEET = 'BottomSheet';
@ -99,6 +100,7 @@ export const SHOW_TRANSLATION = 'ShowTranslation';
export default { export default {
ABOUT, ABOUT,
ACCOUNT, ACCOUNT,
AGENTS,
APPS_FORM, APPS_FORM,
ATTACHMENT_OPTIONS, ATTACHMENT_OPTIONS,
BOTTOM_SHEET, BOTTOM_SHEET,

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {AiBotModel, AiThreadModel} from '@agents/database/models';
import {Database, Q} from '@nozbe/watermelondb'; import {Database, Q} from '@nozbe/watermelondb';
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'; import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';
import logger from '@nozbe/watermelondb/utils/common/logger'; import logger from '@nozbe/watermelondb/utils/common/logger';
@ -47,6 +48,7 @@ class DatabaseManagerSingleton {
constructor() { constructor() {
this.appModels = [InfoModel, GlobalModel, ServersModel]; this.appModels = [InfoModel, GlobalModel, ServersModel];
this.serverModels = [ this.serverModels = [
AiBotModel, AiThreadModel,
CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, CustomProfileFieldModel, CustomProfileAttributeModel, DraftModel, FileModel, CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, CustomProfileFieldModel, CustomProfileAttributeModel, DraftModel, FileModel,
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,

View file

@ -4,6 +4,7 @@
// NOTE : To implement migration, please follow this document // NOTE : To implement migration, please follow this document
// https://nozbe.github.io/WatermelonDB/Advanced/Migrations.html // 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 {addColumns, createTable, schemaMigrations, unsafeExecuteSql} from '@nozbe/watermelondb/Schema/migrations';
import {MM_TABLES} from '@constants/database'; import {MM_TABLES} from '@constants/database';
@ -23,8 +24,38 @@ const {
} = MM_TABLES.SERVER; } = MM_TABLES.SERVER;
const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_ATTRIBUTE, PLAYBOOK_RUN_ATTRIBUTE_VALUE} = PLAYBOOK_TABLES; 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: [ 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, toVersion: 18,
steps: [ steps: [

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // 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 ServerDataOperatorBase from '@database/operator/server_data_operator/handlers';
import CategoryHandler, {type CategoryHandlerMix} from '@database/operator/server_data_operator/handlers/category'; import CategoryHandler, {type CategoryHandlerMix} from '@database/operator/server_data_operator/handlers/category';
import ChannelHandler, {type ChannelHandlerMix} from '@database/operator/server_data_operator/handlers/channel'; 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'; import type {Database} from '@nozbe/watermelondb';
interface ServerDataOperator extends interface ServerDataOperator extends
AgentsHandlerMix,
CategoryHandlerMix, CategoryHandlerMix,
ChannelHandlerMix, ChannelHandlerMix,
CustomProfileHandlerMix, CustomProfileHandlerMix,
@ -31,6 +34,7 @@ interface ServerDataOperator extends
{} {}
class ServerDataOperator extends mix(ServerDataOperatorBase).with( class ServerDataOperator extends mix(ServerDataOperatorBase).with(
AgentsHandler,
CategoryHandler, CategoryHandler,
ChannelHandler, ChannelHandler,
CustomProfileHandler, CustomProfileHandler,

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {AiBotSchema, AiThreadSchema} from '@agents/database/schema';
import {type AppSchema, appSchema} from '@nozbe/watermelondb'; import {type AppSchema, appSchema} from '@nozbe/watermelondb';
import {PlaybookRunSchema, PlaybookChecklistSchema, PlaybookChecklistItemSchema, PlaybookRunAttributeSchema, PlaybookRunAttributeValueSchema} from '@playbooks/database/schema'; import {PlaybookRunSchema, PlaybookChecklistSchema, PlaybookChecklistItemSchema, PlaybookRunAttributeSchema, PlaybookRunAttributeValueSchema} from '@playbooks/database/schema';
@ -45,8 +46,10 @@ import {
} from './table_schemas'; } from './table_schemas';
export const serverSchema: AppSchema = appSchema({ export const serverSchema: AppSchema = appSchema({
version: 18, version: 19,
tables: [ tables: [
AiBotSchema,
AiThreadSchema,
CategorySchema, CategorySchema,
CategoryChannelSchema, CategoryChannelSchema,
ChannelSchema, ChannelSchema,

View file

@ -3,11 +3,15 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import {AGENTS_TABLES} from '@agents/constants/database';
import {MM_TABLES} from '@constants/database'; import {MM_TABLES} from '@constants/database';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database'; import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import {serverSchema} from './index'; import {serverSchema} from './index';
const {AI_BOT, AI_THREAD} = AGENTS_TABLES;
const { const {
CATEGORY, CATEGORY,
CATEGORY_CHANNEL, CATEGORY_CHANNEL,
@ -52,9 +56,53 @@ const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_RUN_A
describe('*** Test schema for SERVER database ***', () => { describe('*** Test schema for SERVER database ***', () => {
it('=> The SERVER SCHEMA should strictly match', () => { it('=> The SERVER SCHEMA should strictly match', () => {
expect(serverSchema).toEqual({ expect(serverSchema).toEqual({
version: 18, version: 19,
unsafeSql: undefined, unsafeSql: undefined,
tables: { 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]: { [CATEGORY]: {
name: CATEGORY, name: CATEGORY,
unsafeSql: undefined, unsafeSql: undefined,

View file

@ -53,6 +53,7 @@ type Props = {
channelIsArchived?: boolean; channelIsArchived?: boolean;
channelIsReadOnly?: boolean; channelIsReadOnly?: boolean;
deactivatedChannel?: boolean; deactivatedChannel?: boolean;
onPostCreated?: (postId: string) => void;
postBoRConfig?: PostBoRConfig; postBoRConfig?: PostBoRConfig;
} }
@ -76,6 +77,7 @@ export const useHandleSendMessage = ({
channelIsReadOnly, channelIsReadOnly,
deactivatedChannel, deactivatedChannel,
clearDraft, clearDraft,
onPostCreated,
postBoRConfig, postBoRConfig,
}: Props) => { }: Props) => {
const intl = useIntl(); const intl = useIntl();
@ -158,20 +160,32 @@ export const useHandleSendMessage = ({
return; 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(); clearDraft();
// Early return to avoid calling DeviceEventEmitter.emit // Early return to avoid calling DeviceEventEmitter.emit
return; return;
} else { } else {
// Response error is handled at the post level so don't have to wait to clear draft // 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(); clearDraft();
} }
setSendingMessage(false); setSendingMessage(false);
DeviceEventEmitter.emit(Events.POST_LIST_SCROLL_TO_BOTTOM, rootId ? Screens.THREAD : Screens.CHANNEL); 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 showSendToAllOrChannelOrHereAlert = useCallback((calculatedMembersCount: number, atHere: boolean, schedulingInfo?: SchedulingInfo) => {
const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(intl, calculatedMembersCount, channelTimezoneCount, atHere); const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(intl, calculatedMembersCount, channelTimezoneCount, atHere);

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager'; import NetworkManager from '@managers/network_manager';
import {getMyChannel} from '@queries/servers/channel'; import {getMyChannel} from '@queries/servers/channel';
import {getFullErrorMessage} from '@utils/errors'; import {getFullErrorMessage} from '@utils/errors';
import {logError} from '@utils/log'; import {logDebug, logError} from '@utils/log';
import type {ChannelAnalysisOptions, ChannelAnalysisResponse} from '@agents/types/api'; 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 // Use empty string for teamId - fetchMyChannel will use channel.team_id if available
const channelResult = await fetchMyChannel(serverUrl, '', channelId); const channelResult = await fetchMyChannel(serverUrl, '', channelId);
if (channelResult.error) { if (channelResult.error) {
logDebug('[requestChannelSummary] Failed to fetch channel', getFullErrorMessage(channelResult.error));
return {error: 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); const result = await client.doChannelAnalysis(channelId, analysisType, botUsername, options);
if (!result?.postid || !result?.channelid) { if (!result?.postid || !result?.channelid) {
logDebug('[requestChannelSummary] Invalid response - missing postid or channelid');
return {error: 'Invalid response from server'}; return {error: 'Invalid response from server'};
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // 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'; import type {Agent, AgentsResponse, AgentsStatusResponse, ChannelAnalysisOptions, ChannelAnalysisResponse, RewriteRequest, RewriteResponse} from '@agents/types/api';
export type {Agent}; export type {Agent};
export interface ClientAgentsMix { export interface ClientAgentsMix {
getAgentsRoute: () => string; getAgentsRoute: () => string;
getAIBots: () => Promise<AIBotsResponse>;
getAIThreads: () => Promise<AIThread[]>;
getAgents: () => Promise<Agent[]>; getAgents: () => Promise<Agent[]>;
stopGeneration: (postId: string) => Promise<void>; stopGeneration: (postId: string) => Promise<void>;
regenerateResponse: (postId: string) => Promise<void>; regenerateResponse: (postId: string) => Promise<void>;
@ -32,6 +34,20 @@ const ClientAgents = (superclass: any) => class extends superclass {
return '/plugins/mattermost-ai'; 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<Agent[]> => { getAgents = async (): Promise<Agent[]> => {
const response = await this.doFetch( const response = await this.doFetch(
`${this.urlVersion}/agents`, `${this.urlVersion}/agents`,

View file

@ -17,6 +17,7 @@ import {useTheme} from '@context/theme';
import {safeParseJSON} from '@utils/helpers'; import {safeParseJSON} from '@utils/helpers';
import {showSnackBar} from '@utils/snack_bar'; import {showSnackBar} from '@utils/snack_bar';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import CitationsList from '../citations_list'; import CitationsList from '../citations_list';
import ControlsBar from '../controls_bar'; import ControlsBar from '../controls_bar';
@ -40,8 +41,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
}, },
messageText: { messageText: {
color: theme.centerChannelColor, color: theme.centerChannelColor,
fontSize: 15, ...typography('Body', 200),
lineHeight: 20,
}, },
precontentContainer: { precontentContainer: {
flexDirection: 'row', flexDirection: 'row',
@ -50,9 +50,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
}, },
precontentText: { precontentText: {
color: changeOpacity(theme.centerChannelColor, 0.6), color: changeOpacity(theme.centerChannelColor, 0.6),
fontSize: 14,
fontStyle: 'italic', fontStyle: 'italic',
marginRight: 8, marginRight: 8,
...typography('Body', 100),
}, },
}; };
}); });

View file

@ -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 (
<Pressable
style={({pressed}) => [pressed && {opacity: 0.72}]}
onPress={handlePress}
testID='channel_list.agents.button'
>
<View style={containerStyle}>
<CompassIcon
name='creation-outline'
style={iconStyle}
testID='channel_list.agents.button-icon'
/>
<FormattedText
id='agents.home_button.title'
defaultMessage='Agents'
style={textStyle}
/>
</View>
</Pressable>
);
};
export default React.memo(AgentsButton);

View file

@ -3,7 +3,7 @@
import {type Agent} from '@agents/client/rest'; import {type Agent} from '@agents/client/rest';
import React, {useCallback, useMemo} from 'react'; 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 CompassIcon from '@components/compass_icon';
import {ExpoImageAnimated} from '@components/expo_image'; 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; const cacheId = agent.id ? `user-${agent.id}` : undefined;
return ( return (
<TouchableOpacity <Pressable
onPress={handlePress} onPress={handlePress}
style={styles.agentRow} style={({pressed}) => [styles.agentRow, pressed && {opacity: 0.72}]}
testID={`agents.selector.agent.${agent.username}`} testID={`agents.selector.agent.${agent.username}`}
> >
<View style={styles.agentInfo}> <View style={styles.agentInfo}>
@ -100,7 +100,7 @@ const AgentItem = React.memo(({agent, profileImageUrl, currentAgentUsername, onS
color={theme.linkColor} color={theme.linkColor}
/> />
)} )}
</TouchableOpacity> </Pressable>
); );
}); });
AgentItem.displayName = 'AgentItem'; AgentItem.displayName = 'AgentItem';

View file

@ -3,7 +3,7 @@
import {type Agent} from '@agents/client/rest'; import {type Agent} from '@agents/client/rest';
import React, {useCallback, useMemo} from 'react'; 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 {buildAbsoluteUrl} from '@actions/remote/file';
import {buildProfileImageUrl} from '@actions/remote/user'; import {buildProfileImageUrl} from '@actions/remote/user';
@ -106,9 +106,9 @@ const AgentSelectorPanel = ({
const renderHeader = useCallback(() => ( const renderHeader = useCallback(() => (
<View style={styles.headerRow}> <View style={styles.headerRow}>
<TouchableOpacity <Pressable
onPress={onBack} onPress={onBack}
style={styles.backButton} style={({pressed}) => [styles.backButton, pressed && {opacity: 0.72}]}
testID='agents.selector.back' testID='agents.selector.back'
> >
<CompassIcon <CompassIcon
@ -116,7 +116,7 @@ const AgentSelectorPanel = ({
size={24} size={24}
color={theme.centerChannelColor} color={theme.centerChannelColor}
/> />
</TouchableOpacity> </Pressable>
<FormattedText <FormattedText
id='agents.selector.title' id='agents.selector.title'
defaultMessage='Select Agent' defaultMessage='Select Agent'

View file

@ -4,7 +4,7 @@
import DateTimePicker, {type DateTimePickerEvent} from '@react-native-community/datetimepicker'; import DateTimePicker, {type DateTimePickerEvent} from '@react-native-community/datetimepicker';
import React, {useCallback, useMemo, useState} from 'react'; import React, {useCallback, useMemo, useState} from 'react';
import {defineMessages, useIntl, type MessageDescriptor} from 'react-intl'; import {defineMessages, useIntl, type MessageDescriptor} from 'react-intl';
import {Platform, TouchableOpacity, View} from 'react-native'; import {Platform, Pressable, View} from 'react-native';
import tinyColor from 'tinycolor2'; import tinyColor from 'tinycolor2';
import Button from '@components/button'; import Button from '@components/button';
@ -104,9 +104,9 @@ const DateInputField = ({label, date, placeholder, onPress, testID, styles, isAc
defaultMessage={label.defaultMessage} defaultMessage={label.defaultMessage}
style={styles.label} style={styles.label}
/> />
<TouchableOpacity <Pressable
onPress={onPress} onPress={onPress}
style={[styles.dateBox, isActive && styles.dateBoxActive]} style={({pressed}) => [styles.dateBox, isActive && styles.dateBoxActive, pressed && {opacity: 0.72}]}
testID={testID} testID={testID}
> >
{date ? ( {date ? (
@ -122,7 +122,7 @@ const DateInputField = ({label, date, placeholder, onPress, testID, styles, isAc
style={styles.datePlaceholder} style={styles.datePlaceholder}
/> />
)} )}
</TouchableOpacity> </Pressable>
</> </>
); );
@ -209,9 +209,9 @@ const DateRangePicker = ({onSubmit, onCancel}: Props) => {
<View style={styles.container}> <View style={styles.container}>
{/* Header with back button */} {/* Header with back button */}
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity <Pressable
onPress={onCancel} onPress={onCancel}
style={styles.backButton} style={({pressed}) => [styles.backButton, pressed && {opacity: 0.72}]}
hitSlop={BACK_BUTTON_HIT_SLOP} hitSlop={BACK_BUTTON_HIT_SLOP}
testID='agents.channel_summary.date_picker.back' testID='agents.channel_summary.date_picker.back'
> >
@ -220,7 +220,7 @@ const DateRangePicker = ({onSubmit, onCancel}: Props) => {
size={24} size={24}
color={theme.centerChannelColor} color={theme.centerChannelColor}
/> />
</TouchableOpacity> </Pressable>
<FormattedText <FormattedText
id='agents.channel_summary.date_picker.title' id='agents.channel_summary.date_picker.title'
defaultMessage='Select date range' defaultMessage='Select date range'

View file

@ -7,7 +7,7 @@ import {type Agent} from '@agents/client/rest';
import {AGENT_ANALYSIS_SUMMARY} from '@agents/constants'; import {AGENT_ANALYSIS_SUMMARY} from '@agents/constants';
import React, {useCallback, useEffect, useState} from 'react'; import React, {useCallback, useEffect, useState} from 'react';
import {defineMessages, useIntl, type MessageDescriptor} from 'react-intl'; import {defineMessages, useIntl, type MessageDescriptor} from 'react-intl';
import {Alert, ScrollView, Text, TouchableOpacity, View} from 'react-native'; import {Alert, Pressable, ScrollView, Text, View} from 'react-native';
import CompassIcon from '@components/compass_icon'; import CompassIcon from '@components/compass_icon';
import FloatingTextInput from '@components/floating_input/floating_text_input_label'; import FloatingTextInput from '@components/floating_input/floating_text_input_label';
@ -300,9 +300,9 @@ const ChannelSummarySheet = ({channelId}: Props) => {
<ScrollView> <ScrollView>
{/* Header Section - Agent selector + Prompt input */} {/* Header Section - Agent selector + Prompt input */}
<View style={styles.headerSection}> <View style={styles.headerSection}>
<TouchableOpacity <Pressable
onPress={handleAgentSelectorOpen} onPress={handleAgentSelectorOpen}
style={styles.agentRow} style={({pressed}) => [styles.agentRow, pressed && {opacity: 0.72}]}
testID='agents.channel_summary.agent_selector' testID='agents.channel_summary.agent_selector'
disabled={submitting || loadingAgents} disabled={submitting || loadingAgents}
> >
@ -325,7 +325,7 @@ const ChannelSummarySheet = ({channelId}: Props) => {
</> </>
)} )}
</View> </View>
</TouchableOpacity> </Pressable>
<View style={styles.promptWrapper}> <View style={styles.promptWrapper}>
<FloatingTextInput <FloatingTextInput
@ -338,11 +338,12 @@ const ChannelSummarySheet = ({channelId}: Props) => {
onSubmitEditing={handleCustomPromptSubmitDebounced} onSubmitEditing={handleCustomPromptSubmitDebounced}
returnKeyType='send' returnKeyType='send'
endAdornment={ endAdornment={
<TouchableOpacity <Pressable
onPress={handleCustomPromptSubmitDebounced} onPress={handleCustomPromptSubmitDebounced}
style={[ style={({pressed}) => [
styles.sendButton, styles.sendButton,
(!customPrompt.trim() || submitting) && styles.sendButtonDisabled, (!customPrompt.trim() || submitting) && styles.sendButtonDisabled,
pressed && {opacity: 0.72},
]} ]}
disabled={!customPrompt.trim() || submitting} disabled={!customPrompt.trim() || submitting}
testID='agents.channel_summary.prompt_submit' testID='agents.channel_summary.prompt_submit'
@ -352,7 +353,7 @@ const ChannelSummarySheet = ({channelId}: Props) => {
size={20} size={20}
color={theme.buttonColor} color={theme.buttonColor}
/> />
</TouchableOpacity> </Pressable>
} }
/> />
</View> </View>

View file

@ -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> = {}): Annotation => ({
type: 'url_citation',
start_index: 0,
end_index: 10,
url: 'https://example.com/article',
title: 'Example Article',
index: 1,
...overrides,
});
const getBaseProps = (): ComponentProps<typeof CitationsList> => ({
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(<CitationsList {...props}/>);
expect(getByText('Sources (2)')).toBeTruthy();
});
it('should render single citation count', () => {
const props = getBaseProps();
const {getByText} = renderWithIntlAndTheme(<CitationsList {...props}/>);
expect(getByText('Sources (1)')).toBeTruthy();
});
});
describe('expand/collapse', () => {
it('should render toggle button', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<CitationsList {...props}/>);
expect(getByTestId('citations.list.toggle')).toBeTruthy();
});
it('should render citation items (always mounted for animation)', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<CitationsList {...props}/>);
// 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(<CitationsList {...props}/>);
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(<CitationsList {...props}/>);
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(<CitationsList {...props}/>);
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(<CitationsList {...props}/>);
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(<CitationsList {...props}/>);
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(<CitationsList {...props}/>);
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(<CitationsList {...props}/>);
fireEvent.press(getByTestId('citations.list.toggle'));
fireEvent.press(getByTestId('citations.list.item.1'));
expect(tryOpenURL).not.toHaveBeenCalled();
});
});
});

View file

@ -3,13 +3,14 @@
import {TOUCH_TARGET_SIZE} from '@agents/constants'; import {TOUCH_TARGET_SIZE} from '@agents/constants';
import React, {useCallback, useEffect, useState} from 'react'; 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 Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon'; import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text'; import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {getUrlDomain, tryOpenURL} from '@utils/url'; import {getUrlDomain, tryOpenURL} from '@utils/url';
import type {Annotation} from '@agents/types'; import type {Annotation} from '@agents/types';
@ -35,10 +36,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
flex: 1, flex: 1,
}, },
headerText: { headerText: {
fontSize: 14,
fontWeight: 600,
color: changeOpacity(theme.centerChannelColor, 0.72), color: changeOpacity(theme.centerChannelColor, 0.72),
marginLeft: 8, marginLeft: 8,
...typography('Body', 100, 'SemiBold'),
}, },
citationsList: { citationsList: {
overflow: 'hidden', overflow: 'hidden',
@ -71,14 +71,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
marginRight: 8, marginRight: 8,
}, },
citationTitle: { citationTitle: {
fontSize: 14,
fontWeight: 600,
color: theme.centerChannelColor, color: theme.centerChannelColor,
marginBottom: 2, marginBottom: 2,
...typography('Body', 100, 'SemiBold'),
}, },
citationUrl: { citationUrl: {
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.64), color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75),
}, },
}; };
}); });
@ -128,9 +127,9 @@ const CitationsList = ({annotations}: CitationsListProps) => {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<TouchableOpacity <Pressable
onPress={handleToggle} onPress={handleToggle}
style={styles.header} style={({pressed}) => [styles.header, pressed && {opacity: 0.72}]}
testID='citations.list.toggle' testID='citations.list.toggle'
> >
<View style={styles.headerLeft}> <View style={styles.headerLeft}>
@ -151,7 +150,7 @@ const CitationsList = ({annotations}: CitationsListProps) => {
size={20} size={20}
color={changeOpacity(theme.centerChannelColor, 0.64)} color={changeOpacity(theme.centerChannelColor, 0.64)}
/> />
</TouchableOpacity> </Pressable>
<Animated.View style={[styles.citationsList, collapsibleStyle]}> <Animated.View style={[styles.citationsList, collapsibleStyle]}>
<View <View
@ -159,10 +158,10 @@ const CitationsList = ({annotations}: CitationsListProps) => {
style={styles.citationsContentWrapper} style={styles.citationsContentWrapper}
> >
{annotations.map((annotation) => ( {annotations.map((annotation) => (
<TouchableOpacity <Pressable
key={`citation-${annotation.index}-${annotation.url}`} key={`citation-${annotation.index}-${annotation.url}`}
onPress={() => handleCitationPress(annotation.url)} onPress={() => handleCitationPress(annotation.url)}
style={styles.citationItem} style={({pressed}) => [styles.citationItem, pressed && {opacity: 0.72}]}
testID={`citations.list.item.${annotation.index}`} testID={`citations.list.item.${annotation.index}`}
> >
<View style={styles.citationIcon}> <View style={styles.citationIcon}>
@ -191,7 +190,7 @@ const CitationsList = ({annotations}: CitationsListProps) => {
size={16} size={16}
color={changeOpacity(theme.centerChannelColor, 0.56)} color={changeOpacity(theme.centerChannelColor, 0.56)}
/> />
</TouchableOpacity> </Pressable>
))} ))}
</View> </View>
</Animated.View> </Animated.View>

View file

@ -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<typeof ControlsBar> => ({
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(<ControlsBar {...props}/>);
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(<ControlsBar {...props}/>);
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(<ControlsBar {...props}/>);
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(<ControlsBar {...props}/>);
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(<ControlsBar {...props}/>);
expect(getByText('Stop Generating')).toBeTruthy();
});
it('should render Regenerate text', () => {
const props = getBaseProps();
props.showRegenerateButton = true;
const {getByText} = renderWithIntlAndTheme(<ControlsBar {...props}/>);
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(<ControlsBar {...props}/>);
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(<ControlsBar {...props}/>);
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(<ControlsBar {...props}/>);
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(<ControlsBar {...props}/>);
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(<ControlsBar {...props}/>);
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();
});
});
});

View file

@ -3,13 +3,14 @@
import React, {useCallback} from 'react'; import React, {useCallback} from 'react';
import {useIntl} from 'react-intl'; 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 CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text'; import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {usePreventDoubleTap} from '@hooks/utils'; import {usePreventDoubleTap} from '@hooks/utils';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return { return {
@ -29,10 +30,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
justifyContent: 'center', justifyContent: 'center',
}, },
buttonText: { buttonText: {
fontSize: 12,
fontWeight: 600,
lineHeight: 16,
color: changeOpacity(theme.centerChannelColor, 0.64), color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75, 'SemiBold'),
}, },
}; };
}); });
@ -92,10 +91,9 @@ const ControlsBar = ({
return ( return (
<View style={styles.container}> <View style={styles.container}>
{showStopButton && ( {showStopButton && (
<TouchableOpacity <Pressable
onPress={handleStop} onPress={handleStop}
style={styles.button} style={({pressed}) => [styles.button, pressed && {opacity: 0.72}]}
activeOpacity={0.7}
testID='agents.controls_bar.stop_button' testID='agents.controls_bar.stop_button'
> >
<CompassIcon <CompassIcon
@ -108,13 +106,12 @@ const ControlsBar = ({
defaultMessage='Stop Generating' defaultMessage='Stop Generating'
style={styles.buttonText} style={styles.buttonText}
/> />
</TouchableOpacity> </Pressable>
)} )}
{showRegenerateButton && ( {showRegenerateButton && (
<TouchableOpacity <Pressable
onPress={handleRegenerate} onPress={handleRegenerate}
style={styles.button} style={({pressed}) => [styles.button, pressed && {opacity: 0.72}]}
activeOpacity={0.7}
testID='agents.controls_bar.regenerate_button' testID='agents.controls_bar.regenerate_button'
> >
<CompassIcon <CompassIcon
@ -127,7 +124,7 @@ const ControlsBar = ({
defaultMessage='Regenerate' defaultMessage='Regenerate'
style={styles.buttonText} style={styles.buttonText}
/> />
</TouchableOpacity> </Pressable>
)} )}
</View> </View>
); );

View file

@ -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) => (
<Svg
width={138}
height={119}
viewBox='0 0 138 119'
fill='none'
>
<G clipPath='url(#clip0_2964_99667)'>
{/* Background ellipse */}
<Ellipse
cx={66}
cy={59.5}
rx={59}
ry={59.5}
fill={theme.centerChannelColor}
fillOpacity={0.08}
/>
{/* Lightbulb fill */}
<Path
d='M66.3565 31.33C66.3565 14.2667 52.493 0.436646 35.3921 0.436646C18.2911 0.436646 4.42401 14.2667 4.42401 31.33C4.42401 44.4926 12.6795 55.7246 24.3056 60.1743V73.8752H46.4785V60.1743C58.1046 55.7246 66.3601 44.4926 66.3601 31.33H66.3565Z'
fill={theme.centerChannelBg}
/>
{/* Lightbulb stroke */}
<Path
d='M24.5002 59.8302L24.1789 59.7072C12.747 55.3317 4.9241 44.2861 4.92401 31.3302C4.92401 14.5443 18.5659 0.936793 35.3918 0.936646C52.2177 0.936646 65.8566 14.5442 65.8566 31.3302V31.6749C65.7056 44.4616 57.1715 55.363 45.8215 59.7072L45.5002 59.8302V73.3751H24.5002V59.8302Z'
stroke={theme.centerChannelColor}
/>
{/* Lightbulb base */}
<Path
d='M24.5004 79.0001C24.2243 79.0001 24.0004 79.2239 24.0004 79.5001C24.0004 79.7762 24.2243 80.0001 24.5004 80.0001V79.5001V79.0001ZM45.5004 79.5001H46.0004V79.0001H45.5004V79.5001ZM45.5004 86.5001V87.0001H46.0004V86.5001H45.5004ZM27.0004 86.5001V86.0001H26.3894L26.5103 86.599L27.0004 86.5001ZM40.5004 91.0001H41.0004C41.0004 90.8122 40.8951 90.6402 40.7278 90.5547C40.5604 90.4693 40.3593 90.4849 40.2072 90.5951L40.5004 91.0001ZM40.5004 102.5H40.0004V103H40.5004V102.5ZM51.0004 103C51.2766 103 51.5004 102.776 51.5004 102.5C51.5004 102.224 51.2766 102 51.0004 102V102.5V103ZM24.5004 79.5001V80.0001H45.5004V79.5001V79.0001H24.5004V79.5001ZM45.5004 79.5001H45.0004V86.5001H45.5004H46.0004V79.5001H45.5004ZM45.5004 86.5001V86.0001H27.0004V86.5001V87.0001H45.5004V86.5001ZM27.0004 86.5001C26.5103 86.599 26.5104 86.5992 26.5104 86.5994C26.5104 86.5995 26.5105 86.5998 26.5105 86.6C26.5106 86.6005 26.5107 86.6011 26.5109 86.6017C26.5111 86.6031 26.5115 86.6048 26.512 86.607C26.5129 86.6114 26.5141 86.6174 26.5158 86.6251C26.5191 86.6404 26.5239 86.6622 26.5302 86.69C26.5429 86.7457 26.5619 86.8255 26.588 86.9261C26.64 87.1272 26.7203 87.412 26.8343 87.7526C27.0618 88.432 27.4268 89.3426 27.9766 90.2576C29.0731 92.0823 30.958 94.0001 34.0004 94.0001V93.5001V93.0001C31.4555 93.0001 29.8404 91.4178 28.8337 89.7425C28.332 88.9075 27.9946 88.0681 27.7826 87.4351C27.6769 87.1194 27.6031 86.8572 27.5561 86.6756C27.5326 86.5848 27.5158 86.5142 27.5051 86.4673C27.4997 86.4439 27.4959 86.4264 27.4935 86.4152C27.4923 86.4096 27.4915 86.4056 27.491 86.4032C27.4907 86.4021 27.4906 86.4013 27.4905 86.4009C27.4905 86.4008 27.4905 86.4007 27.4905 86.4007C27.4905 86.4008 27.4905 86.4009 27.4905 86.4009C27.4905 86.401 27.4905 86.4012 27.0004 86.5001ZM34.0004 93.5001V94.0001C35.6427 94.0001 37.3526 93.3324 38.6178 92.6968C39.2575 92.3755 39.7986 92.0544 40.1803 91.8135C40.3713 91.6929 40.523 91.592 40.6276 91.5207C40.68 91.4851 40.7206 91.4568 40.7486 91.4371C40.7626 91.4272 40.7734 91.4196 40.781 91.4142C40.7847 91.4115 40.7877 91.4093 40.7898 91.4078C40.7909 91.407 40.7917 91.4064 40.7924 91.406C40.7927 91.4057 40.793 91.4055 40.7932 91.4054C40.7933 91.4053 40.7934 91.4052 40.7935 91.4052C40.7936 91.4051 40.7937 91.405 40.5004 91.0001C40.2072 90.5951 40.2072 90.595 40.2073 90.595C40.2073 90.595 40.2073 90.595 40.2073 90.595C40.2074 90.595 40.2073 90.595 40.2072 90.5951C40.207 90.5952 40.2066 90.5955 40.206 90.5959C40.2048 90.5968 40.2027 90.5983 40.1999 90.6003C40.1942 90.6044 40.1853 90.6107 40.1732 90.6192C40.1492 90.6361 40.1126 90.6615 40.0646 90.6942C39.9686 90.7597 39.8267 90.8541 39.6465 90.9679C39.2856 91.1957 38.7732 91.4997 38.1688 91.8033C36.9459 92.4177 35.4058 93.0001 34.0004 93.0001V93.5001ZM40.5004 91.0001H40.0004V102.5H40.5004H41.0004V91.0001H40.5004ZM40.5004 102.5V103H51.0004V102.5V102H40.5004V102.5Z'
fill={theme.centerChannelColor}
/>
{/* Lightbulb internal lines */}
<Path
d='M46.8944 21.6719L37.1616 65.0271'
stroke={theme.centerChannelColor}
strokeLinecap='round'
/>
<Path
d='M23.8895 21.6719L32.7375 65.0271'
stroke={theme.centerChannelColor}
strokeLinecap='round'
/>
{/* Heartbeat line */}
<Path
d='M28.3135 23.1465H30.2662L33.1952 17.2478L34.6597 26.0958L37.5887 18.7225L39.0532 23.1465H42.4704'
stroke={theme.centerChannelColor}
strokeOpacity={0.8}
strokeLinecap='round'
strokeLinejoin='round'
/>
{/* Checklist background */}
<Path
d='M129.835 70.3359H64.8207C63.7162 70.3359 62.8207 71.2314 62.8207 72.3359V109.037C62.8207 110.141 63.7162 111.037 64.8207 111.037H129.835C130.94 111.037 131.835 110.141 131.835 109.037V72.3359C131.835 71.2314 130.94 70.3359 129.835 70.3359Z'
fill={theme.centerChannelColor}
/>
{/* Checklist item 1 circle */}
<Path
d='M118.5 88C122.09 88 125 85.0898 125 81.5C125 77.9102 122.09 75 118.5 75C114.91 75 112 77.9102 112 81.5C112 85.0898 114.91 88 118.5 88Z'
fill={theme.centerChannelBg}
fillOpacity={0.16}
/>
{/* Checklist item 1 checkmark */}
<Path
d='M115.829 81.5628L117.921 83.6545L121.507 80.0687'
stroke={theme.centerChannelBg}
strokeWidth={1.35088}
/>
{/* Checklist item 1 lines */}
<Path
d='M71.0002 79.5H106.5'
stroke={theme.centerChannelBg}
strokeLinecap='round'
/>
<Path
d='M70.9997 84.4927L106.5 84.4927'
stroke={theme.centerChannelBg}
strokeLinecap='round'
/>
{/* Checklist item 2 circle */}
<Path
d='M118.5 106C122.09 106 125 103.09 125 99.5C125 95.9102 122.09 93 118.5 93C114.91 93 112 95.9102 112 99.5C112 103.09 114.91 106 118.5 106Z'
fill={theme.centerChannelBg}
fillOpacity={0.16}
/>
{/* Checklist item 2 checkmark */}
<Path
d='M115.829 99.2588L117.921 101.351L121.507 97.7648'
stroke={theme.centerChannelBg}
strokeWidth={1.35088}
/>
{/* Checklist item 2 lines */}
<Path
d='M70.9997 97.5L107 97.5'
stroke={theme.centerChannelBg}
strokeLinecap='round'
/>
<Path
d='M70.9997 102.5H107'
stroke={theme.centerChannelBg}
strokeLinecap='round'
/>
{/* Decorative stars */}
<Path
fillRule='evenodd'
clipRule='evenodd'
d='M127.443 11.443L121 13.5L127.443 15.557L129.5 22L131.557 15.557L138 13.5L131.557 11.443L129.5 5L127.443 11.443Z'
fill={theme.centerChannelColor}
fillOpacity={0.16}
/>
<Path
fillRule='evenodd'
clipRule='evenodd'
d='M79.79 60.169L76 61.5L79.79 62.831L81 67L82.21 62.831L86 61.5L82.21 60.169L81 56L79.79 60.169Z'
fill={theme.centerChannelColor}
fillOpacity={0.32}
/>
<Path
fillRule='evenodd'
clipRule='evenodd'
d='M4.927 97.169L0 98.5L4.927 99.831L6.5 104L8.073 99.831L13 98.5L8.073 97.169L6.5 93L4.927 97.169Z'
fill={theme.centerChannelColor}
fillOpacity={0.16}
/>
{/* Chat bubble fill */}
<Path
d='M95.5963 47.8982L88.4805 55V21.0175C88.4805 19.9129 89.3759 19.0175 90.4805 19.0175H122C123.105 19.0175 124 19.9129 124 21.0175V45.8982C124 47.0028 123.105 47.8982 122 47.8982H95.5963Z'
fill={theme.centerChannelBg}
/>
{/* Chat bubble overlay */}
<Path
d='M95.5963 47.8982L88.4805 55V21.0175C88.4805 19.9129 89.3759 19.0175 90.4805 19.0175H122C123.105 19.0175 124 19.9129 124 21.0175V45.8982C124 47.0028 123.105 47.8982 122 47.8982H95.5963Z'
fill={theme.centerChannelColor}
fillOpacity={0.4}
/>
{/* Chat bubble lines */}
<Path
d='M97 27H108'
stroke={theme.centerChannelColor}
strokeLinecap='round'
/>
<Path
d='M97 32H116'
stroke={theme.centerChannelColor}
strokeLinecap='round'
/>
<Path
d='M97 37H105'
stroke={theme.centerChannelColor}
strokeLinecap='round'
/>
<Path
d='M107 37H114'
stroke={theme.centerChannelColor}
strokeLinecap='round'
/>
{/* Magnifying glass circle */}
<Circle
cx={124.316}
cy={48.6583}
r={10.56}
stroke={theme.centerChannelColor}
strokeLinecap='round'
strokeLinejoin='round'
/>
{/* Magnifying glass reflection */}
<Path
d='M132.72 49.1007C132.72 53.4987 129.155 57.0639 124.757 57.0639'
stroke={theme.centerChannelColor}
strokeOpacity={0.32}
strokeLinecap='round'
strokeLinejoin='round'
/>
{/* Magnifying glass handle */}
<Line
x1={117.559}
y1={57.2736}
x2={111.309}
y2={63.5239}
stroke={theme.centerChannelColor}
strokeLinecap='round'
strokeLinejoin='round'
/>
{/* Bottom decorative lines */}
<Path
d='M55 102.5H57'
stroke={theme.centerChannelColor}
strokeLinecap='round'
/>
<Path
d='M60 102.5H65'
stroke={theme.centerChannelColor}
strokeLinecap='round'
/>
{/* Decorative arcs around lightbulb */}
<Path
d='M60.5145 5.86743C67.9401 13.5398 71.9243 24.403 70.4339 35.8131C69.2488 44.8855 64.8075 52.7348 58.4392 58.3388'
stroke={theme.centerChannelColor}
strokeOpacity={0.32}
strokeLinecap='round'
/>
<Path
d='M63 62C71.6023 54.3833 77 43.414 77 31.2209C77 25.4668 75.7979 19.9853 73.6243 15'
stroke={theme.centerChannelColor}
strokeOpacity={0.32}
strokeLinecap='round'
strokeDasharray='2 2'
/>
</G>
<Defs>
<ClipPath id='clip0_2964_99667'>
<Rect
width={138}
height={119}
fill={theme.centerChannelBg}
/>
</ClipPath>
</Defs>
</Svg>
);
export default AgentsIntro;

View file

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

View file

@ -3,13 +3,14 @@
import {TOUCH_TARGET_SIZE} from '@agents/constants'; import {TOUCH_TARGET_SIZE} from '@agents/constants';
import React, {useCallback, useState} from 'react'; 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 Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon'; import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text'; import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import LoadingSpinner from './loading_spinner'; import LoadingSpinner from './loading_spinner';
@ -18,6 +19,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
minimalContainer: { minimalContainer: {
marginBottom: 4, marginBottom: 4,
minHeight: TOUCH_TARGET_SIZE, minHeight: TOUCH_TARGET_SIZE,
marginLeft: -14,
}, },
minimalContent: { minimalContent: {
flexDirection: 'row', flexDirection: 'row',
@ -26,12 +28,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
paddingVertical: 12, paddingVertical: 12,
}, },
minimalText: { minimalText: {
fontSize: 14,
lineHeight: 20,
color: changeOpacity(theme.centerChannelColor, 0.64), color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 100),
}, },
expandedContainer: { expandedContainer: {
marginBottom: 16, marginBottom: 16,
marginLeft: -15,
}, },
expandedHeader: { expandedHeader: {
flexDirection: 'row', flexDirection: 'row',
@ -42,9 +44,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
paddingVertical: 12, paddingVertical: 12,
}, },
expandedHeaderText: { expandedHeaderText: {
fontSize: 14,
lineHeight: 20,
color: changeOpacity(theme.centerChannelColor, 0.64), color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 100),
}, },
reasoningContentContainer: { reasoningContentContainer: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.02), backgroundColor: changeOpacity(theme.centerChannelColor, 0.02),
@ -58,9 +59,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
}, },
reasoningText: { reasoningText: {
padding: 16, padding: 16,
fontSize: 14,
lineHeight: 22,
color: changeOpacity(theme.centerChannelColor, 0.8), color: changeOpacity(theme.centerChannelColor, 0.8),
...typography('Body', 100),
}, },
}; };
}); });
@ -98,10 +98,9 @@ const ReasoningDisplay = ({reasoningSummary, isReasoningLoading}: ReasoningDispl
return ( return (
<View style={isExpanded ? styles.expandedContainer : styles.minimalContainer}> <View style={isExpanded ? styles.expandedContainer : styles.minimalContainer}>
<TouchableOpacity <Pressable
onPress={handleToggle} onPress={handleToggle}
style={isExpanded ? styles.expandedHeader : styles.minimalContent} style={({pressed}) => [isExpanded ? styles.expandedHeader : styles.minimalContent, pressed && {opacity: 0.72}]}
activeOpacity={0.7}
> >
<Animated.View style={chevronAnimatedStyle}> <Animated.View style={chevronAnimatedStyle}>
<CompassIcon <CompassIcon
@ -118,7 +117,7 @@ const ReasoningDisplay = ({reasoningSummary, isReasoningLoading}: ReasoningDispl
defaultMessage='Thinking' defaultMessage='Thinking'
style={isExpanded ? styles.expandedHeaderText : styles.minimalText} style={isExpanded ? styles.expandedHeaderText : styles.minimalText}
/> />
</TouchableOpacity> </Pressable>
{isExpanded && reasoningSummary ? ( {isExpanded && reasoningSummary ? (
<Animated.View style={[styles.reasoningContentContainer, contentAnimatedStyle]}> <Animated.View style={[styles.reasoningContentContainer, contentAnimatedStyle]}>
<View style={styles.reasoningContent}> <View style={styles.reasoningContent}>

View file

@ -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 = () => <View testID='loading-spinner'/>;
MockLoadingSpinner.displayName = 'MockLoadingSpinner';
return MockLoadingSpinner;
});
describe('ReasoningDisplay', () => {
const getBaseProps = (): ComponentProps<typeof ReasoningDisplay> => ({
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(<ReasoningDisplay {...props}/>);
expect(getByText('Thinking')).toBeTruthy();
});
it('should show loading spinner when isReasoningLoading is true', () => {
const props = getBaseProps();
props.isReasoningLoading = true;
const {getByTestId} = renderWithIntlAndTheme(<ReasoningDisplay {...props}/>);
// 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(<ReasoningDisplay {...props}/>);
// 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(<ReasoningDisplay {...props}/>);
// 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(<ReasoningDisplay {...props}/>);
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(<ReasoningDisplay {...props}/>);
// 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(<ReasoningDisplay {...props}/>);
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(<ReasoningDisplay {...props}/>);
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(<ReasoningDisplay {...props}/>);
fireEvent.press(getByText('Thinking'));
expect(getByText('A'.repeat(1000))).toBeTruthy();
});
});
});

View file

@ -3,10 +3,10 @@
import {useRewrite} from '@agents/hooks'; import {useRewrite} from '@agents/hooks';
import React, {useEffect} from 'react'; import React, {useEffect} from 'react';
import {useIntl} from 'react-intl'; import {StyleSheet, View} from 'react-native';
import {StyleSheet, Text, View} from 'react-native';
import Animated, {cancelAnimation, Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming} from 'react-native-reanimated'; 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 StatusIndicator from '@components/post_draft/status_indicator';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {changeOpacity} from '@utils/theme'; import {changeOpacity} from '@utils/theme';
@ -40,7 +40,6 @@ const getStyleSheet = (theme: Theme) => StyleSheet.create({
function RewritingIndicator() { function RewritingIndicator() {
const {isProcessing} = useRewrite(); const {isProcessing} = useRewrite();
const theme = useTheme(); const theme = useTheme();
const intl = useIntl();
const styles = getStyleSheet(theme); const styles = getStyleSheet(theme);
const rotation = useSharedValue(0); const rotation = useSharedValue(0);
@ -65,12 +64,11 @@ function RewritingIndicator() {
<StatusIndicator visible={isProcessing}> <StatusIndicator visible={isProcessing}>
<View style={styles.container}> <View style={styles.container}>
<Animated.View style={[styles.spinner, spinnerAnimatedStyle]}/> <Animated.View style={[styles.spinner, spinnerAnimatedStyle]}/>
<Text style={styles.text}> <FormattedText
{intl.formatMessage({ id='ai_rewrite.rewriting'
id: 'ai_rewrite.rewriting', defaultMessage='Rewriting...'
defaultMessage: 'Rewriting...', style={styles.text}
})} />
</Text>
</View> </View>
</StatusIndicator> </StatusIndicator>
); );

View file

@ -14,6 +14,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {showSnackBar} from '@utils/snack_bar'; import {showSnackBar} from '@utils/snack_bar';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import ToolCard from '../tool_card'; import ToolCard from '../tool_card';
@ -36,6 +37,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
container: { container: {
marginTop: 8, marginTop: 8,
marginBottom: 12, marginBottom: 12,
marginLeft: -15,
gap: 8, gap: 8,
}, },
statusBar: { statusBar: {
@ -48,8 +50,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
borderRadius: 4, borderRadius: 4,
}, },
statusText: { statusText: {
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.64), color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75),
}, },
}; };
}); });

View file

@ -3,7 +3,7 @@
import {ToolApprovalStage, ToolCallStatus, type ToolCall} from '@agents/types'; import {ToolApprovalStage, ToolCallStatus, type ToolCall} from '@agents/types';
import React, {useCallback, useEffect, useMemo} from 'react'; 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 Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon'; import CompassIcon from '@components/compass_icon';
@ -17,6 +17,12 @@ import {safeParseJSON} from '@utils/helpers';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography'; 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 { interface ToolCardProps {
tool: ToolCall; tool: ToolCall;
isCollapsed: boolean; isCollapsed: boolean;
@ -34,7 +40,6 @@ interface ToolCardProps {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return { return {
container: { container: {
marginBottom: 4,
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
@ -55,7 +60,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
...typography('Body', 100), ...typography('Body', 100),
}, },
argumentsContainer: { argumentsContainer: {
marginLeft: 24, marginLeft: CONTENT_INDENT,
}, },
markdownText: { markdownText: {
color: changeOpacity(theme.centerChannelColor, 0.75), color: changeOpacity(theme.centerChannelColor, 0.75),
@ -67,21 +72,21 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
gap: 8, gap: 8,
paddingTop: 8, paddingTop: 8,
paddingBottom: 8, paddingBottom: 8,
paddingLeft: 24, paddingLeft: CONTENT_INDENT,
}, },
responseLabelText: { responseLabelText: {
color: changeOpacity(theme.centerChannelColor, 0.75), color: changeOpacity(theme.centerChannelColor, 0.75),
...typography('Body', 100), ...typography('Body', 100),
}, },
resultContainer: { resultContainer: {
marginLeft: 24, marginLeft: CONTENT_INDENT,
}, },
statusContainer: { statusContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
marginTop: 16, marginTop: 16,
paddingLeft: 24, paddingLeft: CONTENT_INDENT,
}, },
statusText: { statusText: {
color: changeOpacity(theme.centerChannelColor, 0.75), color: changeOpacity(theme.centerChannelColor, 0.75),
@ -105,6 +110,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
paddingVertical: 8, paddingVertical: 8,
paddingHorizontal: 16, paddingHorizontal: 16,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center',
minHeight: 32,
}, },
buttonDisabled: { buttonDisabled: {
opacity: 0.5, opacity: 0.5,
@ -306,10 +313,9 @@ const ToolCard = ({
style={styles.container} style={styles.container}
testID={testIdPrefix} testID={testIdPrefix}
> >
<TouchableOpacity <Pressable
onPress={canExpand ? handleToggle : undefined} onPress={canExpand ? handleToggle : undefined}
style={styles.header} style={({pressed}) => [styles.header, canExpand && pressed && {opacity: 0.72}]}
activeOpacity={canExpand ? 0.7 : 1}
testID={`${testIdPrefix}.header`} testID={`${testIdPrefix}.header`}
> >
{canExpand ? ( {canExpand ? (
@ -329,7 +335,7 @@ const ToolCard = ({
> >
{displayName} {displayName}
</Text> </Text>
</TouchableOpacity> </Pressable>
{!isCollapsed && ( {!isCollapsed && (
<Animated.View style={contentAnimatedStyle}> <Animated.View style={contentAnimatedStyle}>
@ -447,11 +453,11 @@ const ToolCard = ({
{isPending && !hasLocalDecision && !isProcessing && onApprove && onReject && ( {isPending && !hasLocalDecision && !isProcessing && onApprove && onReject && (
<View style={styles.buttonContainer}> <View style={styles.buttonContainer}>
<TouchableOpacity <Pressable
onPress={handleApprove} onPress={handleApprove}
disabled={isProcessing} disabled={isProcessing}
style={[styles.button, isProcessing && styles.buttonDisabled]} style={({pressed}) => [styles.button, isProcessing && styles.buttonDisabled, pressed && {opacity: 0.72}]}
activeOpacity={0.7} hitSlop={BUTTON_HIT_SLOP}
testID={`${testIdPrefix}.approve`} testID={`${testIdPrefix}.approve`}
> >
<FormattedText <FormattedText
@ -459,12 +465,12 @@ const ToolCard = ({
defaultMessage='Accept' defaultMessage='Accept'
style={styles.buttonText} style={styles.buttonText}
/> />
</TouchableOpacity> </Pressable>
<TouchableOpacity <Pressable
onPress={handleReject} onPress={handleReject}
disabled={isProcessing} disabled={isProcessing}
style={[styles.button, isProcessing && styles.buttonDisabled]} style={({pressed}) => [styles.button, isProcessing && styles.buttonDisabled, pressed && {opacity: 0.72}]}
activeOpacity={0.7} hitSlop={BUTTON_HIT_SLOP}
testID={`${testIdPrefix}.reject`} testID={`${testIdPrefix}.reject`}
> >
<FormattedText <FormattedText
@ -472,17 +478,16 @@ const ToolCard = ({
defaultMessage='Reject' defaultMessage='Reject'
style={styles.buttonText} style={styles.buttonText}
/> />
</TouchableOpacity> </Pressable>
</View> </View>
)} )}
{isResultPhase && (isSuccess || isError) && !hasLocalDecision && !isProcessing && onApprove && onReject && ( {isResultPhase && (isSuccess || isError) && !hasLocalDecision && !isProcessing && onApprove && onReject && (
<View style={styles.resultButtonContainer}> <View style={styles.resultButtonContainer}>
<TouchableOpacity <Pressable
onPress={handleApprove} onPress={handleApprove}
disabled={isProcessing} disabled={isProcessing}
style={[styles.shareButton, isProcessing && styles.buttonDisabled]} style={({pressed}) => [styles.shareButton, isProcessing && styles.buttonDisabled, pressed && {opacity: 0.72}]}
activeOpacity={0.7}
testID={`${testIdPrefix}.share`} testID={`${testIdPrefix}.share`}
> >
<CompassIcon <CompassIcon
@ -495,12 +500,11 @@ const ToolCard = ({
defaultMessage='Share' defaultMessage='Share'
style={styles.shareButtonText} style={styles.shareButtonText}
/> />
</TouchableOpacity> </Pressable>
<TouchableOpacity <Pressable
onPress={handleReject} onPress={handleReject}
disabled={isProcessing} disabled={isProcessing}
style={[styles.keepPrivateButton, isProcessing && styles.buttonDisabled]} style={({pressed}) => [styles.keepPrivateButton, isProcessing && styles.buttonDisabled, pressed && {opacity: 0.72}]}
activeOpacity={0.7}
testID={`${testIdPrefix}.keep_private`} testID={`${testIdPrefix}.keep_private`}
> >
<CompassIcon <CompassIcon
@ -513,7 +517,7 @@ const ToolCard = ({
defaultMessage='Keep private' defaultMessage='Keep private'
style={styles.keepPrivateButtonText} style={styles.keepPrivateButtonText}
/> />
</TouchableOpacity> </Pressable>
</View> </View>
)} )}
</View> </View>

View file

@ -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}) => (
<Text testID='mock-markdown'>{value}</Text>
);
return MockMarkdown;
});
describe('ToolCard', () => {
const createMockTool = (overrides: Partial<ToolCall> = {}): ToolCall => ({
id: 'tool-123',
name: 'search_documents',
description: 'Search through documents',
arguments: {query: 'test query'},
status: ToolCallStatus.Pending,
...overrides,
});
const getBaseProps = (): ComponentProps<typeof ToolCard> => ({
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(<ToolCard {...props}/>);
expect(getByText('Search Documents')).toBeTruthy();
});
it('should handle single word names', () => {
const props = getBaseProps();
props.tool = createMockTool({name: 'search'});
const {getByText} = renderWithIntlAndTheme(<ToolCard {...props}/>);
expect(getByText('Search')).toBeTruthy();
});
it('should handle multiple underscores', () => {
const props = getBaseProps();
props.tool = createMockTool({name: 'get_user_profile_data'});
const {getByText} = renderWithIntlAndTheme(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
expect(getByText('Processing...')).toBeTruthy();
});
it('should hide buttons for non-pending status', () => {
const props = getBaseProps();
props.tool = createMockTool({status: ToolCallStatus.Success});
const {queryByText} = renderWithIntlAndTheme(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
// 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(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
// 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(<ToolCard {...props}/>);
// Mocked Markdown should not be present when collapsed
expect(queryAllByTestId('mock-markdown').length).toBe(0);
});
});
describe('result phase UI', () => {
const getResultPhaseProps = (): ComponentProps<typeof ToolCard> => ({
...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(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
expect(queryByText('Share')).toBeNull();
expect(queryByText('Keep private')).toBeNull();
});
it('should call onApprove when share is pressed', () => {
const props = getResultPhaseProps();
const {getByText} = renderWithIntlAndTheme(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
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(<ToolCard {...props}/>);
expect(queryByTestId('agents.tool_card.tool-123.warning')).toBeNull();
});
});
});

View file

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

View file

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

View file

@ -1,10 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // 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_SELECTOR = 'AgentsSelector';
export const AGENTS_REWRITE_OPTIONS = 'AgentsRewriteOptions'; export const AGENTS_REWRITE_OPTIONS = 'AgentsRewriteOptions';
export default { export default {
AGENT_CHAT,
AGENT_THREADS_LIST,
AGENTS_SELECTOR, AGENTS_SELECTOR,
AGENTS_REWRITE_OPTIONS, AGENTS_REWRITE_OPTIONS,
} as const; } as const;

View file

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

View file

@ -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[];
}

View file

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

View file

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

View file

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

View file

@ -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<Model[]>;
handleAIThreads: (args: HandleAIThreadsArgs) => Promise<Model[]>;
}
const AgentsHandler = <TBase extends Constructor<ServerDataOperatorBase>>(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<Model[]>} - A promise that resolves to an array of handled AI bot records.
*/
handleAIBots = async ({bots, prepareRecordsOnly}: HandleAIBotsArgs): Promise<Model[]> => {
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<AiBotModel>(AI_BOT).query().fetch();
const existingRecordsMap = new Map(existingRecords.map((record) => [record.id, record]));
const createOrUpdateRaws = uniqueRaws.reduce<LLMBot[]>((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<Model[]>} - A promise that resolves to an array of handled AI thread records.
*/
handleAIThreads = async ({threads, prepareRecordsOnly}: HandleAIThreadsArgs): Promise<Model[]> => {
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<AiThreadModel>(AI_THREAD).query().fetch();
const existingRecordsMap = new Map(existingRecords.map((record) => [record.id, record]));
const createOrUpdateRaws = uniqueRaws.reduce<AIThread[]>((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;

View file

@ -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<AiBotModel, LLMBot>): Promise<AiBotModel> => {
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<AiThreadModel, AIThread>): Promise<AiThreadModel> => {
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,
});
};

View file

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

View file

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

View file

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

View file

@ -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<SystemModel>(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<string> {
const systems = await queryAgentsVersion(database).fetch();
return systems[0]?.value || '';
}
export function observeIsAgentsEnabled(database: Database) {
return database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).query(
Q.where('id', SYSTEM_IDENTIFIERS.AGENTS_VERSION),
).observeWithColumns(['value']).pipe(
switchMap((systems) => {
return of$(isAgentsEnabledFromSystemModel(systems));
}),
);
}

View file

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

View file

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

View file

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

View file

@ -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 <PortalContext.Provider value={{}}>{children}</PortalContext.Provider>;
},
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 <View testID='portal'>{children}</View>;
},
};
});
// --- Context mocks ---
jest.mock('@context/server', () => ({
useServerUrl: jest.fn(() => SERVER_URL),
withServerUrl: (Component: React.ComponentType<any>) => (props: any) => (
<Component
{...props}
serverUrl={SERVER_URL}
/>
),
}));
// --- 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: () => <View testID='mock-agents-intro'/>};
});
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(
<AgentChat
componentId={Screens.AGENT_CHAT}
bots={[mockBot]}
/>,
{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(
<AgentChat
componentId={Screens.AGENT_CHAT}
bots={[]}
/>,
{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();
});
});

View file

@ -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<AiBotModel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [channelId, setChannelId] = useState<string | null>(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 (
<BotSelectorItem
key={bot.id}
bot={bot}
avatarUrl={avatarUrl}
isSelected={selectedBot?.id === bot.id}
onSelect={handleBotSelect}
theme={theme}
/>
);
})}
</>
);
};
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 (
<View style={[styles.container, {paddingTop: insets.top}]}>
<Loading
containerStyle={styles.loadingContainer}
size='large'
color={theme.buttonBg}
/>
</View>
);
}
return (
<View
style={styles.container}
>
{/* Header */}
<View style={[styles.headerContainer, {paddingTop: insets.top}]}>
<View style={styles.headerContent}>
{/* Left - Back button */}
<View style={styles.headerLeft}>
<Pressable
onPress={exit}
style={({pressed}) => [styles.headerIconButton, pressed && {opacity: 0.72}]}
testID='agent_chat.back_button'
>
<CompassIcon
name='arrow-left'
size={20}
color={changeOpacity(theme.sidebarText, 0.56)}
/>
</Pressable>
</View>
{/* Center - Title and bot selector */}
<Pressable
onPress={handleBotSelectorPress}
style={({pressed}) => [styles.headerCenter, pressed && bots.length > 1 && {opacity: 0.72}]}
disabled={bots.length <= 1}
testID='agent_chat.bot_selector'
>
<FormattedText
id='agents.chat.title'
defaultMessage='Agents'
style={styles.headerTitle}
/>
<View style={styles.headerSubtitle}>
{selectedBot ? (
<Text style={styles.headerSubtitleText}>
{selectedBot.displayName}
</Text>
) : (
<FormattedText
id='agents.chat.select_agent'
defaultMessage='Select an agent'
style={styles.headerSubtitleText}
/>
)}
{bots.length > 1 && (
<CompassIcon
name='chevron-down'
size={12}
color={changeOpacity(theme.sidebarText, 0.72)}
/>
)}
</View>
</Pressable>
{/* Right - History icon */}
<View style={styles.headerRight}>
<Pressable
onPress={handleHistoryPress}
style={({pressed}) => [styles.headerIconButton, pressed && {opacity: 0.72}]}
testID='agent_chat.history_button'
>
<CompassIcon
name='clock-outline'
size={20}
color={theme.sidebarText}
/>
</Pressable>
</View>
</View>
</View>
{/* Main content */}
<PortalProvider>
<View
style={styles.mainContent}
onLayout={onLayout}
>
<View style={styles.content}>
<View style={styles.introContent}>
<AgentsIntro theme={theme}/>
<FormattedText
id='agents.chat.intro_title'
defaultMessage='Ask Agents anything'
style={styles.welcomeText}
/>
<FormattedText
id='agents.chat.intro_description'
defaultMessage='Agents are here to help.'
style={styles.descriptionText}
/>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
</View>
{channelId && (
<PostDraft
channelId={channelId}
testID='agent_chat.post_draft'
containerHeight={containerHeight}
isChannelScreen={false}
location={Screens.AGENT_CHAT}
onPostCreated={handlePostCreated}
/>
)}
</View>
</PortalProvider>
</View>
);
};
export default AgentChat;

View file

@ -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;
}) => (
<TouchableOpacity
testID={testID}
onPress={onPress}
>
<Text testID={`${testID}.text`}>{text}</Text>
<View testID={`${testID}.leftIcon`}>
<Text>{typeof leftIcon === 'string' ? leftIcon : leftIcon.uri}</Text>
</View>
{rightIcon && (
<View testID={`${testID}.rightIcon`}>
<Text>{rightIcon}</Text>
</View>
)}
</TouchableOpacity>
);
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<typeof BotSelectorItem> => ({
bot: mockBot,
isSelected: false,
onSelect: jest.fn(),
theme: Preferences.THEMES.denim,
});
it('should render bot display name', () => {
const props = getBaseProps();
const {getByText} = render(<BotSelectorItem {...props}/>);
expect(getByText('Test Bot')).toBeTruthy();
});
it('should call onSelect with bot when pressed', () => {
const props = getBaseProps();
const {getByTestId} = render(<BotSelectorItem {...props}/>);
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(<BotSelectorItem {...props}/>);
// 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(<BotSelectorItem {...props}/>);
// 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(<BotSelectorItem {...props}/>);
// 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(<BotSelectorItem {...props}/>);
// Fallback icon name should be used
expect(getByText('account-outline')).toBeTruthy();
});
});

View file

@ -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 (
<SlideUpPanelItem
leftIcon={avatarUrl ? {uri: avatarUrl} : 'account-outline'}
leftImageStyles={{borderRadius: 12}}
onPress={handlePress}
testID={`agent_chat.bot_selector.bot_item.${bot.id}`}
text={bot.displayName}
rightIcon={isSelected ? 'check' : undefined}
rightIconStyles={{color: theme.linkColor}}
/>
);
};
export default BotSelectorItem;

View file

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

View file

@ -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<string | null>(null);
// Create a map of channel_id to bot display name
const botNameByChannelId = useMemo(() => {
const map: Record<string, string> = {};
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<AiThreadModel> = useCallback(({item}) => {
return (
<ThreadItem
thread={item}
onPress={handleThreadPress}
botName={botNameByChannelId[item.channelId]}
theme={theme}
/>
);
}, [botNameByChannelId, handleThreadPress, theme]);
const renderEmptyState = useCallback(() => {
if (error) {
return (
<View style={styles.emptyContainer}>
<CompassIcon
name='alert-circle-outline'
size={64}
color={theme.errorTextColor}
style={styles.emptyIcon}
/>
<FormattedText
id='agents.threads_list.error_title'
defaultMessage='Unable to load conversations'
style={[styles.emptyTitle, {color: theme.errorTextColor}]}
/>
<Text style={styles.errorText}>{error}</Text>
</View>
);
}
return (
<View style={styles.emptyContainer}>
<CompassIcon
name='forum-outline'
size={64}
color={changeOpacity(theme.centerChannelColor, 0.32)}
style={styles.emptyIcon}
/>
<FormattedText
id='agents.threads_list.empty_title'
defaultMessage='No conversations yet'
style={styles.emptyTitle}
/>
<FormattedText
id='agents.threads_list.empty_description'
defaultMessage='Your conversations with agents will appear here. Start a new conversation to get started.'
style={styles.emptyDescription}
/>
</View>
);
}, [error, styles, theme]);
// Show loading only on first load with no cached data
if (loading && threads.length === 0) {
return (
<Loading
containerStyle={styles.loadingContainer}
size='large'
color={theme.buttonBg}
/>
);
}
return (
<View style={styles.container}>
{/* Header */}
<View style={[styles.headerContainer, {paddingTop: insets.top}]}>
<View style={styles.headerContent}>
{/* Left - Back button */}
<View style={styles.headerLeft}>
<Pressable
onPress={exit}
style={({pressed}) => [styles.headerIconButton, pressed && {opacity: 0.72}]}
testID='agent_threads_list.back_button'
>
<CompassIcon
name='arrow-left'
size={20}
color={changeOpacity(theme.sidebarText, 0.56)}
/>
</Pressable>
</View>
{/* Center - Title */}
<View style={styles.headerCenter}>
<FormattedText
id='agents.threads_list.title'
defaultMessage='Agent chat history'
style={styles.headerTitle}
/>
</View>
{/* Right - New chat button */}
<View style={styles.headerRight}>
<Pressable
onPress={handleNewChat}
style={({pressed}) => [styles.headerIconButton, pressed && {opacity: 0.72}]}
testID='agent_threads_list.new_chat_button'
>
<CompassIcon
name='plus'
size={20}
color={theme.sidebarText}
/>
</Pressable>
</View>
</View>
</View>
{/* Main content */}
<View style={styles.mainContent}>
<FlashList
data={threads}
renderItem={renderItem}
estimatedItemSize={THREAD_ITEM_HEIGHT}
contentContainerStyle={styles.listContent}
ListEmptyComponent={renderEmptyState}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
colors={[theme.buttonBg]}
tintColor={theme.buttonBg}
/>
}
testID='agent_threads_list.flat_list'
/>
</View>
</View>
);
};
export default AgentThreadsList;

View file

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

View file

@ -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<typeof ThreadItem> => ({
thread: mockThread,
onPress: jest.fn(),
theme: Preferences.THEMES.denim,
});
it('should render thread title', () => {
const props = getBaseProps();
const {getByText} = renderWithIntlAndTheme(<ThreadItem {...props}/>);
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(<ThreadItem {...props}/>);
expect(getByText('Conversation with Agents')).toBeTruthy();
});
it('should render message preview when present', () => {
const props = getBaseProps();
const {getByText} = renderWithIntlAndTheme(<ThreadItem {...props}/>);
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(<ThreadItem {...props}/>);
// 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(<ThreadItem {...props}/>);
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(<ThreadItem {...props}/>);
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(<ThreadItem {...props}/>);
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(<ThreadItem {...props}/>);
expect(getByText('0 replies')).toBeTruthy();
});
it('should render bot name tag when provided', () => {
const props = getBaseProps();
props.botName = 'AI Assistant';
const {getByText} = renderWithIntlAndTheme(<ThreadItem {...props}/>);
expect(getByText('AI Assistant')).toBeTruthy();
});
it('should not render bot name tag when not provided', () => {
const props = getBaseProps();
// botName is undefined
const {queryByText} = renderWithIntlAndTheme(<ThreadItem {...props}/>);
// 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(<ThreadItem {...props}/>);
expect(getByTestId('agent_thread.unique-thread-id')).toBeTruthy();
});
});

View file

@ -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 (
<Pressable
onPress={handlePress}
style={({pressed}) => [styles.threadItem, pressed && {opacity: 0.72}]}
testID={`agent_thread.${thread.id}`}
>
<View style={styles.threadContent}>
<View style={styles.threadHeader}>
<Text
style={styles.threadTitle}
numberOfLines={1}
>
{thread.title || intl.formatMessage({
id: 'agents.threads_list.default_title',
defaultMessage: 'Conversation with Agents',
})}
</Text>
<FormattedRelativeTime
value={thread.updateAt}
style={styles.threadTimestamp}
/>
</View>
{thread.message && (
<Text
style={styles.threadPreview}
numberOfLines={2}
>
{thread.message}
</Text>
)}
<View style={styles.threadMeta}>
<CompassIcon
name='reply-outline'
size={14}
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
<FormattedText
id='agents.threads_list.reply_count'
defaultMessage='{count, plural, one {# reply} other {# replies}}'
values={{count: thread.replyCount}}
style={styles.threadReplyCount}
/>
{botName && (
<View style={styles.agentTag}>
<Text style={styles.agentTagText}>
{botName}
</Text>
</View>
)}
</View>
</View>
</Pressable>
);
};
export default ThreadItem;

View file

@ -7,6 +7,10 @@ import {withServerDatabase} from '@database/components';
export function loadAgentsScreen(screenName: string | number) { export function loadAgentsScreen(screenName: string | number) {
switch (screenName) { 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: case Screens.AGENTS_SELECTOR:
return withServerDatabase(require('@agents/screens/agent_selector').default); return withServerDatabase(require('@agents/screens/agent_selector').default);
case Screens.AGENTS_REWRITE_OPTIONS: case Screens.AGENTS_REWRITE_OPTIONS:

View file

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

View file

@ -20,6 +20,7 @@ import {dismissBottomSheet, openAsBottomSheet} from '@screens/navigation';
import {bottomSheetSnapPoint} from '@utils/helpers'; import {bottomSheetSnapPoint} from '@utils/helpers';
import {logWarning} from '@utils/log'; import {logWarning} from '@utils/log';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {Agent, RewriteAction} from '@agents/types'; import type {Agent, RewriteAction} from '@agents/types';
import type {AvailableScreens} from '@typings/screens/navigation'; import type {AvailableScreens} from '@typings/screens/navigation';
@ -122,12 +123,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
}, },
customPromptInput: { customPromptInput: {
flex: 1, flex: 1,
fontSize: 16,
lineHeight: 24,
color: theme.centerChannelColor, color: theme.centerChannelColor,
padding: 0,
margin: 0,
includeFontPadding: false, includeFontPadding: false,
...typography('Body', 200),
textAlignVertical: 'center', textAlignVertical: 'center',
verticalAlign: 'middle', verticalAlign: 'middle',
justifyContent: 'center', justifyContent: 'center',

View file

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

View file

@ -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<ChannelModel>;
}
export default AiThreadModel;

View file

@ -78,6 +78,67 @@ export interface StreamingState {
annotations: Annotation[]; // Citations/annotations for the post 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 // Rewrite Types
// ============================================================================ // ============================================================================

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import AgentsButton from '@agents/components/agents_button';
import React, {useEffect, useMemo, useState} from 'react'; import React, {useEffect, useMemo, useState} from 'react';
import {DeviceEventEmitter, useWindowDimensions} from 'react-native'; import {DeviceEventEmitter, useWindowDimensions} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; 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 DraftsButton from '@components/drafts_buttton';
import ThreadsButton from '@components/threads_button'; import ThreadsButton from '@components/threads_button';
import {Events, Screens} from '@constants'; 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 {TABLET_SIDEBAR_WIDTH, TEAM_SIDEBAR_WIDTH} from '@constants/view';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device'; import {useIsTablet} from '@hooks/device';
@ -38,6 +39,7 @@ type ChannelListProps = {
scheduledPostHasError: boolean; scheduledPostHasError: boolean;
lastChannelId?: string; lastChannelId?: string;
scheduledPostsEnabled?: boolean; scheduledPostsEnabled?: boolean;
agentsEnabled?: boolean;
showPlaybooksButton?: boolean; showPlaybooksButton?: boolean;
}; };
@ -45,7 +47,7 @@ const getTabletWidth = (moreThanOneTeam: boolean) => {
return TABLET_SIDEBAR_WIDTH - (moreThanOneTeam ? TEAM_SIDEBAR_WIDTH : 0); 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 = ({ const CategoriesList = ({
hasChannels, hasChannels,
@ -57,6 +59,7 @@ const CategoriesList = ({
scheduledPostHasError, scheduledPostHasError,
lastChannelId, lastChannelId,
scheduledPostsEnabled, scheduledPostsEnabled,
agentsEnabled,
showPlaybooksButton, showPlaybooksButton,
}: ChannelListProps) => { }: ChannelListProps) => {
const theme = useTheme(); const theme = useTheme();
@ -77,7 +80,7 @@ const CategoriesList = ({
useEffect(() => { useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.ACTIVE_SCREEN, (screen: string) => { const listener = DeviceEventEmitter.addListener(Events.ACTIVE_SCREEN, (screen: string) => {
if (screen === DRAFT || screen === THREAD) { if (screen === AGENTS || screen === DRAFT || screen === THREAD) {
setActiveScreen(screen); setActiveScreen(screen);
} else { } else {
setActiveScreen(CHANNEL); setActiveScreen(CHANNEL);
@ -127,6 +130,18 @@ const CategoriesList = ({
return null; return null;
}, [activeScreen, draftsCount, isTablet, scheduledPostCount, scheduledPostHasError, scheduledPostsEnabled]); }, [activeScreen, draftsCount, isTablet, scheduledPostCount, scheduledPostHasError, scheduledPostsEnabled]);
const agentsButtonComponent = useMemo(() => {
if (!agentsEnabled) {
return null;
}
return (
<AgentsButton
shouldHighlightActive={activeScreen === AGENTS}
/>
);
}, [agentsEnabled, activeScreen]);
const playbooksButtonComponent = useMemo(() => { const playbooksButtonComponent = useMemo(() => {
if (!showPlaybooksButton) { if (!showPlaybooksButton) {
return null; return null;
@ -147,11 +162,12 @@ const CategoriesList = ({
<SubHeader/> <SubHeader/>
{threadButtonComponent} {threadButtonComponent}
{draftsButtonComponent} {draftsButtonComponent}
{agentsButtonComponent}
{playbooksButtonComponent} {playbooksButtonComponent}
<Categories isTablet={isTablet}/> <Categories isTablet={isTablet}/>
</> </>
); );
}, [draftsButtonComponent, hasChannels, isTablet, playbooksButtonComponent, threadButtonComponent]); }, [agentsButtonComponent, draftsButtonComponent, hasChannels, isTablet, playbooksButtonComponent, threadButtonComponent]);
return ( return (
<Animated.View style={[styles.container, tabletStyle]}> <Animated.View style={[styles.container, tabletStyle]}>

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {observeIsAgentsEnabled} from '@agents/database/queries/version';
import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {combineLatest, of} from 'rxjs'; import {combineLatest, of} from 'rxjs';
import {switchMap} from 'rxjs/operators'; import {switchMap} from 'rxjs/operators';
@ -29,6 +30,7 @@ const enchanced = withObservables([], ({database}: WithDatabaseArgs) => {
switchMap((scheduledPosts) => of(hasScheduledPostError(scheduledPosts))), switchMap((scheduledPosts) => of(hasScheduledPostError(scheduledPosts))),
); );
const scheduledPostsEnabled = observeScheduledPostEnabled(database); const scheduledPostsEnabled = observeScheduledPostEnabled(database);
const agentsEnabled = observeIsAgentsEnabled(database);
const showPlaybooksButton = currentTeamId.pipe( const showPlaybooksButton = currentTeamId.pipe(
switchMap((teamId) => combineLatest([ switchMap((teamId) => combineLatest([
observeIsPlaybooksEnabled(database), observeIsPlaybooksEnabled(database),
@ -43,6 +45,7 @@ const enchanced = withObservables([], ({database}: WithDatabaseArgs) => {
scheduledPostCount, scheduledPostCount,
scheduledPostHasError, scheduledPostHasError,
scheduledPostsEnabled, scheduledPostsEnabled,
agentsEnabled,
showPlaybooksButton, showPlaybooksButton,
}; };
}); });

View file

@ -28,10 +28,18 @@
"agents.channel_summary.since_placeholder": "Select date", "agents.channel_summary.since_placeholder": "Select date",
"agents.channel_summary.until": "End", "agents.channel_summary.until": "End",
"agents.channel_summary.until_placeholder": "Select date", "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.citations.title": "Sources ({count})",
"agents.controls.regenerate": "Regenerate", "agents.controls.regenerate": "Regenerate",
"agents.controls.stop": "Stop Generating", "agents.controls.stop": "Stop Generating",
"agents.generating": "Generating response...", "agents.generating": "Generating response...",
"agents.home_button.title": "Agents",
"agents.reasoning.thinking": "Thinking", "agents.reasoning.thinking": "Thinking",
"agents.regenerate.cancel": "Cancel", "agents.regenerate.cancel": "Cancel",
"agents.regenerate.confirm": "Regenerate", "agents.regenerate.confirm": "Regenerate",
@ -39,6 +47,13 @@
"agents.regenerate.confirm_title": "Regenerate Response", "agents.regenerate.confirm_title": "Regenerate Response",
"agents.selector.no_agents": "No agents available", "agents.selector.no_agents": "No agents available",
"agents.selector.title": "Select Agent", "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.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.approve": "Accept",
"agents.tool_call.keep_private": "Keep private", "agents.tool_call.keep_private": "Keep private",

View file

@ -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. # Please bump the version by 1, any time the schema changes.
# Also, include the migration plan under app/database/migration/server, # Also, include the migration plan under app/database/migration/server,
# update all models, relationships and types. # update all models, relationships and types.
@ -422,3 +422,27 @@ status string
timezone string timezone string
update_at number update_at number
username string 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