diff --git a/CLAUDE-IOS-SIMULATOR.md b/CLAUDE-IOS-SIMULATOR.md
new file mode 100644
index 000000000..705b1c427
--- /dev/null
+++ b/CLAUDE-IOS-SIMULATOR.md
@@ -0,0 +1,172 @@
+# iOS Simulator Setup Guide for AI Agents
+
+This guide documents the process for setting up and running the Mattermost Mobile app on an iOS simulator.
+
+## Prerequisites Check
+
+Before starting, verify these are installed:
+
+```bash
+# Check Xcode
+xcodebuild -version
+
+# Check CocoaPods
+pod --version
+
+# Check Node.js
+node --version
+
+# Check npm dependencies
+test -d node_modules && echo "node_modules exists" || echo "Run: npm install"
+```
+
+## Step 1: iOS Simulator Runtime
+
+Check if iOS simulators are available:
+
+```bash
+xcrun simctl list devices available
+```
+
+If no devices are listed or you see "Install Started", you need to download the iOS platform:
+
+```bash
+xcodebuild -downloadPlatform iOS
+```
+
+**Note:** This downloads ~8GB and takes several minutes. The command runs in the foreground and shows progress.
+
+## Step 2: CocoaPods Installation
+
+### Critical: Set UTF-8 Encoding
+
+CocoaPods requires UTF-8 encoding. Always set this before running pod commands:
+
+```bash
+export LANG=en_US.UTF-8
+export LC_ALL=en_US.UTF-8
+```
+
+### Install Pods (Apple Silicon Mac)
+
+```bash
+cd ios
+RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
+```
+
+### Common CocoaPods Issues
+
+#### Issue: "Unicode Normalization not appropriate for ASCII-8BIT"
+**Solution:** Set UTF-8 encoding as shown above.
+
+#### Issue: Podfile.lock version conflicts
+**Solution:** Clean install:
+```bash
+cd ios
+rm -rf Pods Podfile.lock build
+export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
+RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
+```
+
+#### Issue: GitHub SSH timeouts during pod install
+**Symptom:** Errors like "Connection to github.com port 22: Operation timed out"
+
+**Solution:** Temporarily configure git to use HTTPS instead of SSH:
+```bash
+git config --global url."https://github.com/".insteadOf git@github.com:
+```
+
+**IMPORTANT:** Revert this after pod install completes, or the user won't be able to push:
+```bash
+git config --global --unset url.https://github.com/.insteadof
+```
+
+**Warning:** Never leave git URL rewrites in place without informing the user!
+
+## Step 3: Start Metro Bundler (if not already running)
+
+```bash
+npm start
+```
+
+Wait for "Dev server ready" message before proceeding.
+
+## Step 4: Run iOS App (if not already built)
+
+### Using npm script
+```bash
+npm run ios -- --simulator="iPhone 17 Pro"
+```
+
+### Or specify a different simulator
+List available simulators first:
+```bash
+xcrun simctl list devices available | grep -E "iPhone|iPad"
+```
+
+Then run with your chosen device:
+```bash
+npm run ios -- --simulator="iPhone 16e"
+```
+
+## Build Times
+
+- **First build:** 10-30 minutes (compiles all native code)
+- **Subsequent builds:** Much faster (incremental)
+- **JS/TS changes:** ~3 seconds hot reload (no native rebuild needed)
+
+## Troubleshooting Build Failures
+
+### Getting Better Error Messages
+
+If `npm run ios` fails, run xcodebuild directly for clearer errors:
+
+```bash
+xcodebuild -workspace ios/Mattermost.xcworkspace \
+ -configuration Debug \
+ -scheme Mattermost \
+ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
+ 2>&1 | grep -E "error:|fatal error"
+```
+
+### Clean Build
+
+If builds fail mysteriously, try a clean build:
+
+```bash
+# Clean Xcode derived data
+rm -rf ~/Library/Developer/Xcode/DerivedData/Mattermost-*
+
+# Clean and reinstall pods
+cd ios
+rm -rf Pods build
+export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
+RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
+```
+
+### Opening in Xcode
+
+For complex build issues, open in Xcode for better debugging:
+
+```bash
+open ios/Mattermost.xcworkspace
+```
+
+Then select your simulator target and press Cmd+B to build.
+
+## Quick Reference
+
+```bash
+# Full setup sequence (Apple Silicon)
+export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
+cd ios && RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install && cd ..
+npm start &
+sleep 10
+npm run ios -- --simulator="iPhone 17 Pro"
+```
+
+## Do NOT Modify
+
+- **Git configuration:** Never permanently change git URL rewrites without reverting
+- **Podfile:** Don't add modular_headers to pods unless you understand the implications
+- **New Architecture:** Keep `RCT_NEW_ARCH_ENABLED=0` as the project uses the old architecture
diff --git a/CLAUDE.md b/CLAUDE.md
index b44550798..56cf2a60a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -27,6 +27,8 @@ When testing features that require mobile device interaction:
## Key Commands
+> **iOS Simulator Setup:** For detailed iOS simulator setup instructions, troubleshooting CocoaPods issues, and build debugging, see [CLAUDE-IOS-SIMULATOR.md](./CLAUDE-IOS-SIMULATOR.md).
+
```bash
# Setup
npm run pod-install # iOS CocoaPods (or pod-install-m1 for M1 Macs)
diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts
index 6e77e27fc..8238b21fe 100644
--- a/app/actions/remote/channel.ts
+++ b/app/actions/remote/channel.ts
@@ -1468,6 +1468,42 @@ export const fetchGroupMessageMembersCommonTeams = async (serverUrl: string, cha
}
};
+export async function switchToChannelByName(serverUrl: string, channelName: string, teamName?: string) {
+ try {
+ const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
+
+ let teamId = '';
+ if (teamName) {
+ const team = await getTeamByName(database, teamName);
+ teamId = team?.id || '';
+ }
+ if (!teamId) {
+ teamId = await getCurrentTeamId(database) || '';
+ }
+
+ let channelId = '';
+ const channel = await getChannelByName(database, teamId, channelName);
+ if (channel) {
+ channelId = channel.id;
+ } else {
+ const fetchResult = await fetchChannelByName(serverUrl, teamId, channelName, true);
+ if (fetchResult.channel) {
+ channelId = fetchResult.channel.id;
+ }
+ }
+
+ if (channelId) {
+ await switchToChannelById(serverUrl, channelId, teamId);
+ }
+
+ return {};
+ } catch (error) {
+ logDebug('error on switchToChannelByName', getFullErrorMessage(error));
+ forceLogoutIfNecessary(serverUrl, error);
+ return {error};
+ }
+}
+
export const convertGroupMessageToPrivateChannel = async (serverUrl: string, channelId: string, targetTeamId: string, displayName: string) => {
try {
const name = generateChannelNameFromDisplayName(displayName);
diff --git a/app/actions/websocket/event.ts b/app/actions/websocket/event.ts
index 53604db05..3bdc62ac7 100644
--- a/app/actions/websocket/event.ts
+++ b/app/actions/websocket/event.ts
@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import {checkIsAgentsPluginEnabled} from '@agents/actions/remote/agents_status';
import {handleAgentPostUpdate} from '@agents/actions/websocket';
import * as bookmark from '@actions/local/channel_bookmark';
@@ -274,7 +275,7 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
case WebsocketEvents.PLUGIN_STATUSES_CHANGED:
case WebsocketEvents.PLUGIN_ENABLED:
case WebsocketEvents.PLUGIN_DISABLED:
- // Do nothing, this event doesn't need logic in the mobile app
+ checkIsAgentsPluginEnabled(serverUrl);
break;
// bookmarks
diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts
index 13b2a171e..ca8664927 100644
--- a/app/actions/websocket/index.ts
+++ b/app/actions/websocket/index.ts
@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import {checkIsAgentsPluginEnabled} from '@agents/actions/remote/agents_status';
+
import {markChannelAsViewed} from '@actions/local/channel';
import {dataRetentionCleanup, expiredBoRPostCleanup} from '@actions/local/systems';
import {markChannelAsRead} from '@actions/remote/channel';
@@ -95,6 +97,8 @@ async function doReconnect(serverUrl: string, groupLabel?: BaseRequestGroupLabel
loadConfigAndCalls(serverUrl, currentUserId, groupLabel);
}
+ checkIsAgentsPluginEnabled(serverUrl);
+
await deferredAppEntryActions(serverUrl, lastFullSync, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, meData, initialTeamId, undefined, groupLabel);
await setLastFullSync(operator, now);
diff --git a/app/components/channel_actions/ask_agents_option/ask_agents_option.tsx b/app/components/channel_actions/ask_agents_option/ask_agents_option.tsx
new file mode 100644
index 000000000..b5e387b3a
--- /dev/null
+++ b/app/components/channel_actions/ask_agents_option/ask_agents_option.tsx
@@ -0,0 +1,76 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import ChannelSummarySheet from '@agents/components/channel_summary_sheet';
+import React, {useCallback} from 'react';
+import {useIntl} from 'react-intl';
+import {Platform} from 'react-native';
+
+import OptionItem from '@components/option_item';
+import SlideUpPanelItem from '@components/slide_up_panel_item';
+import {useTheme} from '@context/theme';
+import {bottomSheet, dismissBottomSheet} from '@screens/navigation';
+
+type Props = {
+ channelId: string;
+ showAsLabel?: boolean;
+ testID?: string;
+}
+
+const AskAgentsOption = ({
+ channelId,
+ showAsLabel,
+ testID,
+}: Props) => {
+ const intl = useIntl();
+ const theme = useTheme();
+
+ const label = intl.formatMessage({id: 'agents.channel_summary.ask_agents', defaultMessage: 'Ask Agents'});
+
+ const openSheet = useCallback(async () => {
+ // First dismiss the current quick actions bottom sheet
+ await dismissBottomSheet();
+
+ const renderContent = () => (
+
+ );
+
+ bottomSheet({
+ title: label,
+ renderContent,
+ closeButtonId: 'close-channel-summary',
+ enableDynamicSizing: true,
+
+ // iOS needs more space to accommodate the inline date picker spinner
+ // Android uses a modal date picker, so it needs less space
+ snapPoints: [1, Platform.select({ios: '60%', default: '40%'})!],
+ theme,
+ initialSnapIndex: 1,
+ });
+ }, [channelId, label, theme]);
+
+ if (showAsLabel) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default AskAgentsOption;
diff --git a/app/components/channel_actions/ask_agents_option/index.ts b/app/components/channel_actions/ask_agents_option/index.ts
new file mode 100644
index 000000000..582b0dccd
--- /dev/null
+++ b/app/components/channel_actions/ask_agents_option/index.ts
@@ -0,0 +1,7 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import AskAgentsOption from './ask_agents_option';
+
+export default AskAgentsOption;
+
diff --git a/app/components/markdown/inline_entity_link/index.tsx b/app/components/markdown/inline_entity_link/index.tsx
new file mode 100644
index 000000000..81d6bd762
--- /dev/null
+++ b/app/components/markdown/inline_entity_link/index.tsx
@@ -0,0 +1,127 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useCallback} from 'react';
+import {Text, View} from 'react-native';
+
+import {switchToChannelByName} from '@actions/remote/channel';
+import {showPermalink} from '@actions/remote/permalink';
+import {handleTeamChange} from '@actions/remote/team';
+import CompassIcon from '@components/compass_icon';
+import {useServerUrl} from '@context/server';
+import {useTheme} from '@context/theme';
+import {usePreventDoubleTap} from '@hooks/utils';
+import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
+
+export const INLINE_ENTITY_TYPE = {
+ POST: 'POST',
+ CHANNEL: 'CHANNEL',
+ TEAM: 'TEAM',
+} as const;
+
+export type InlineEntityType = typeof INLINE_ENTITY_TYPE[keyof typeof INLINE_ENTITY_TYPE];
+
+type InlineEntityLinkProps = {
+ entityType: InlineEntityType;
+ entityId: string;
+ linkUrl?: string;
+};
+
+// Size matches web implementation (18px for mobile, 22px on web)
+const CONTAINER_SIZE = 18;
+const ICON_SIZE = 10;
+
+// Vertical offset to center the icon with the middle of the text line
+// This shifts the icon down to align with the visual center of text (accounting for descenders)
+// Using transform translateY for more reliable positioning of inline Views
+// TODO: This value is hardcoded for a particular font size and may need adjustment for different text sizes
+const VERTICAL_OFFSET = 3;
+
+const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
+
+ // Circular container matching web styling
+ container: {
+ width: CONTAINER_SIZE,
+ height: CONTAINER_SIZE,
+ borderRadius: CONTAINER_SIZE / 2,
+ borderWidth: 1,
+ borderColor: changeOpacity(theme.centerChannelColor, 0.12),
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
+ alignItems: 'center',
+ justifyContent: 'center',
+
+ // Use transform for more reliable vertical positioning
+ transform: [{translateY: VERTICAL_OFFSET}],
+ },
+ icon: {
+ color: changeOpacity(theme.centerChannelColor, 0.64),
+ },
+}));
+
+/**
+ * Extract team name from a citation URL.
+ * URL format: http://server/teamSlug/pl/postId or http://server/teamSlug/channels/channelName
+ */
+function extractTeamNameFromUrl(url: string): string | undefined {
+ try {
+ const urlObj = new URL(url);
+ const pathParts = urlObj.pathname.split('/').filter(Boolean);
+
+ // First path segment is typically the team name
+ if (pathParts.length > 0) {
+ return pathParts[0];
+ }
+ } catch {
+ // URL parsing failed
+ }
+ return undefined;
+}
+
+const InlineEntityLink = ({entityType, entityId, linkUrl}: InlineEntityLinkProps) => {
+ const theme = useTheme();
+ const serverUrl = useServerUrl();
+ const styles = getStyleSheet(theme);
+
+ const handlePress = useCallback(async () => {
+ if (!entityId) {
+ return;
+ }
+
+ switch (entityType) {
+ case INLINE_ENTITY_TYPE.POST: {
+ const teamName = linkUrl ? extractTeamNameFromUrl(linkUrl) : undefined;
+ await showPermalink(serverUrl, teamName || '', entityId);
+ break;
+ }
+ case INLINE_ENTITY_TYPE.CHANNEL: {
+ const teamName = linkUrl ? extractTeamNameFromUrl(linkUrl) : undefined;
+ await switchToChannelByName(serverUrl, entityId, teamName);
+ break;
+ }
+ case INLINE_ENTITY_TYPE.TEAM: {
+ await handleTeamChange(serverUrl, entityId);
+ break;
+ }
+ }
+ }, [entityType, entityId, linkUrl, serverUrl]);
+
+ const preventDoubleTap = usePreventDoubleTap(handlePress);
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default InlineEntityLink;
+
diff --git a/app/components/markdown/markdown.tsx b/app/components/markdown/markdown.tsx
index 8add4accc..bc87eb5f0 100644
--- a/app/components/markdown/markdown.tsx
+++ b/app/components/markdown/markdown.tsx
@@ -12,6 +12,7 @@ import CompassIcon from '@components/compass_icon';
import EditedIndicator from '@components/edited_indicator';
import Emoji from '@components/emoji';
import FormattedText from '@components/formatted_text';
+import {useServerUrl} from '@context/server';
import {logError} from '@utils/log';
import {computeTextStyle, getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@@ -20,6 +21,7 @@ import {typography} from '@utils/typography';
import AtMention from './at_mention';
import ChannelMention from './channel_mention';
import Hashtag from './hashtag';
+import InlineEntityLink, {type InlineEntityType} from './inline_entity_link';
import MarkdownBlockQuote from './markdown_block_quote';
import MarkdownCodeBlock from './markdown_code_block';
import MarkdownImage from './markdown_image';
@@ -32,7 +34,7 @@ import MarkdownTable from './markdown_table';
import MarkdownTableCell, {type MarkdownTableCellProps} from './markdown_table_cell';
import MarkdownTableImage from './markdown_table_image';
import MarkdownTableRow, {type MarkdownTableRowProps} from './markdown_table_row';
-import {addListItemIndices, combineTextNodes, highlightMentions, highlightWithoutNotification, highlightSearchPatterns, parseTaskLists, pullOutImages} from './transform';
+import {addListItemIndices, combineTextNodes, highlightMentions, highlightWithoutNotification, highlightSearchPatterns, parseTaskLists, processInlineEntities, pullOutImages} from './transform';
import type {ChannelMentions} from './channel_mention/channel_mention';
import type {
@@ -116,6 +118,12 @@ const getExtraPropsForNode = (node: any) => {
extraProps.isChecked = node.isChecked;
}
+ if (node.type === 'inline_entity_link') {
+ extraProps.entityType = node.entityType;
+ extraProps.entityId = node.entityId;
+ extraProps.linkUrl = node.linkUrl;
+ }
+
return extraProps;
};
@@ -178,6 +186,7 @@ const Markdown = ({
const blockStyles = useMemo(() => getMarkdownBlockStyles(theme), [theme]);
const textStyles = useMemo(() => getMarkdownTextStyles(theme), [theme]);
const managedConfig = useManagedConfig();
+ const serverUrl = useServerUrl();
const renderText = useCallback(({context, literal}: MarkdownBaseRenderer) => {
const selectable = (managedConfig.copyAndPasteProtection !== 'true') && context.includes('table_cell');
@@ -285,6 +294,19 @@ const Markdown = ({
);
}, [theme.centerChannelColor]);
+ const renderInlineEntityLink = useCallback(({entityType, entityId, linkUrl}: {entityType: InlineEntityType; entityId: string; linkUrl?: string}) => {
+ if (!entityType || !entityId) {
+ return null;
+ }
+ return (
+
+ );
+ }, []);
+
const renderCodeBlock = useCallback((props: any) => {
if (disableCodeBlock) {
return null;
@@ -617,6 +639,7 @@ const Markdown = ({
search_highlight: Renderer.forwardChildren,
highlight_without_notification: Renderer.forwardChildren,
checkbox: renderCheckbox,
+ inline_entity_link: renderInlineEntityLink,
editedIndicator: renderEditedIndicator,
maxNodesWarning: renderMaxNodesWarning,
@@ -660,6 +683,7 @@ const Markdown = ({
renderTableRow,
renderTableCell,
renderCheckbox,
+ renderInlineEntityLink,
renderEditedIndicator,
renderMaxNodesWarning,
maxNodes,
@@ -676,6 +700,7 @@ const Markdown = ({
ast = addListItemIndices(ast);
ast = pullOutImages(ast);
ast = parseTaskLists(ast);
+ ast = processInlineEntities(ast, serverUrl);
if (mentionKeys) {
ast = highlightMentions(ast, mentionKeys);
}
@@ -731,7 +756,7 @@ const Markdown = ({
/>
);
}
- }, [highlightKeys, isEdited, mentionKeys, parser, renderer, searchPatterns, style.errorMessage, value]);
+ }, [highlightKeys, isEdited, mentionKeys, parser, renderer, searchPatterns, serverUrl, style.errorMessage, value]);
return output;
};
diff --git a/app/components/markdown/transform.test.ts b/app/components/markdown/transform.test.ts
index bcecd4c97..c960fe051 100644
--- a/app/components/markdown/transform.test.ts
+++ b/app/components/markdown/transform.test.ts
@@ -3258,6 +3258,355 @@ describe('Components.Markdown.transform', () => {
});
}
});
+
+ describe('parseCitationUrl', () => {
+ const {parseCitationUrl} = require('@components/markdown/transform');
+
+ // Valid 26-character post ID for testing
+ const validPostId1 = 'hod5do9pc38q7e7ofcc7e3hzae';
+
+ it('should return null for URLs without view=citation query param', () => {
+ expect(parseCitationUrl(`http://localhost:8066/team/pl/${validPostId1}`)).toBeNull();
+ expect(parseCitationUrl('http://localhost:8066/team/channels/general')).toBeNull();
+ expect(parseCitationUrl('')).toBeNull();
+ });
+
+ it('should return null when view=citation appears in path but not as query param', () => {
+ // view=citation must be a query parameter, not just appear anywhere in URL
+ expect(parseCitationUrl('http://localhost:8066/view=citation/team/pl/abc123')).toBeNull();
+ });
+
+ it('should parse post citation URLs with valid 26-char post ID', () => {
+ const result = parseCitationUrl(`http://localhost:8066/missionops/pl/${validPostId1}?view=citation`);
+ expect(result).toEqual({
+ entityType: 'POST',
+ entityId: validPostId1,
+ linkUrl: `http://localhost:8066/missionops/pl/${validPostId1}?view=citation`,
+ });
+ });
+
+ it('should return null for post IDs that are not exactly 26 characters', () => {
+ // Too short
+ expect(parseCitationUrl('http://localhost:8066/team/pl/abc123?view=citation')).toBeNull();
+
+ // Too long
+ expect(parseCitationUrl('http://localhost:8066/team/pl/abc123def456ghi789jkl012mnopq?view=citation')).toBeNull();
+ });
+
+ it('should parse channel citation URLs with valid channel names', () => {
+ const result = parseCitationUrl('http://localhost:8066/missionops/channels/mission-planning?view=citation');
+ expect(result).toEqual({
+ entityType: 'CHANNEL',
+ entityId: 'mission-planning',
+ linkUrl: 'http://localhost:8066/missionops/channels/mission-planning?view=citation',
+ });
+ });
+
+ it('should parse channel citation URLs with underscores and dots', () => {
+ const result = parseCitationUrl('http://localhost:8066/team/channels/my_channel.name?view=citation');
+ expect(result).toEqual({
+ entityType: 'CHANNEL',
+ entityId: 'my_channel.name',
+ linkUrl: 'http://localhost:8066/team/channels/my_channel.name?view=citation',
+ });
+ });
+
+ it('should return null for team-only URLs (not a supported citation entity)', () => {
+ const result = parseCitationUrl('http://localhost:8066/missionops?view=citation');
+ expect(result).toBeNull();
+ });
+
+ it('should handle view=citation with additional query params', () => {
+ const result = parseCitationUrl(`http://localhost:8066/team/pl/${validPostId1}?foo=bar&view=citation`);
+ expect(result).toEqual({
+ entityType: 'POST',
+ entityId: validPostId1,
+ linkUrl: `http://localhost:8066/team/pl/${validPostId1}?foo=bar&view=citation`,
+ });
+ });
+
+ describe('with serverUrl for internal link validation', () => {
+ const serverUrl = 'http://localhost:8066';
+
+ it('should parse internal links when serverUrl matches', () => {
+ const result = parseCitationUrl(
+ `http://localhost:8066/team/pl/${validPostId1}?view=citation`,
+ serverUrl,
+ );
+ expect(result).toEqual({
+ entityType: 'POST',
+ entityId: validPostId1,
+ linkUrl: `http://localhost:8066/team/pl/${validPostId1}?view=citation`,
+ });
+ });
+
+ it('should return null for external links when serverUrl is provided', () => {
+ const result = parseCitationUrl(
+ `http://external-server.com/team/pl/${validPostId1}?view=citation`,
+ serverUrl,
+ );
+ expect(result).toBeNull();
+ });
+
+ it('should return null when ports do not match', () => {
+ const result = parseCitationUrl(
+ `http://localhost:9000/team/pl/${validPostId1}?view=citation`,
+ serverUrl,
+ );
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('with server subpaths', () => {
+ const serverUrlWithSubpath = 'http://localhost:8066/mattermost';
+
+ it('should handle server URLs with subpaths', () => {
+ const result = parseCitationUrl(
+ `http://localhost:8066/mattermost/team/pl/${validPostId1}?view=citation`,
+ serverUrlWithSubpath,
+ );
+ expect(result).toEqual({
+ entityType: 'POST',
+ entityId: validPostId1,
+ linkUrl: `http://localhost:8066/mattermost/team/pl/${validPostId1}?view=citation`,
+ });
+ });
+
+ it('should handle channel URLs with subpaths', () => {
+ const result = parseCitationUrl(
+ 'http://localhost:8066/mattermost/team/channels/general?view=citation',
+ serverUrlWithSubpath,
+ );
+ expect(result).toEqual({
+ entityType: 'CHANNEL',
+ entityId: 'general',
+ linkUrl: 'http://localhost:8066/mattermost/team/channels/general?view=citation',
+ });
+ });
+
+ it('should return null for team-only URLs with subpaths (not a supported citation entity)', () => {
+ const result = parseCitationUrl(
+ 'http://localhost:8066/mattermost/myteam?view=citation',
+ serverUrlWithSubpath,
+ );
+ expect(result).toBeNull();
+ });
+ });
+
+ it('should handle encoded URLs', () => {
+ const encodedUrl = `http://localhost:8066/team/channels/${encodeURIComponent('channel-name')}?view=citation`;
+ const result = parseCitationUrl(encodedUrl);
+ expect(result).toEqual({
+ entityType: 'CHANNEL',
+ entityId: 'channel-name',
+ linkUrl: encodedUrl,
+ });
+ });
+
+ describe('non-internal URLs with similar shape', () => {
+ const serverUrl = 'http://localhost:8066';
+
+ it('should reject external URLs that mimic internal link structure', () => {
+ // External server with same path structure as a Mattermost permalink
+ const result = parseCitationUrl(
+ `http://evil-site.com/team/pl/${validPostId1}?view=citation`,
+ serverUrl,
+ );
+ expect(result).toBeNull();
+ });
+
+ it('should reject URLs from similar-looking domains', () => {
+ // Subdomain that might try to look like the real server
+ const result = parseCitationUrl(
+ `http://localhost.evil.com:8066/team/pl/${validPostId1}?view=citation`,
+ serverUrl,
+ );
+ expect(result).toBeNull();
+ });
+
+ it('should reject URLs with matching path but different protocol handling', () => {
+ // Same structure but on a different server entirely
+ const result = parseCitationUrl(
+ 'http://mattermost-lookalike.com/team/channels/general?view=citation',
+ serverUrl,
+ );
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('URLs with escaped slashes', () => {
+ it('should handle URLs with encoded slashes in channel names', () => {
+ // Channel name with encoded slash - should not match since %2F is not a valid channel character
+ const result = parseCitationUrl('http://localhost:8066/team/channels/channel%2Fname?view=citation');
+
+ // The URL decodes to channel/name which has a slash, making it invalid
+ expect(result).toBeNull();
+ });
+
+ it('should handle URLs with encoded characters that decode to valid patterns', () => {
+ // %2D is encoded hyphen, which should decode and work
+ const result = parseCitationUrl('http://localhost:8066/team/channels/my%2Dchannel?view=citation');
+ expect(result).toEqual({
+ entityType: 'CHANNEL',
+ entityId: 'my-channel',
+ linkUrl: 'http://localhost:8066/team/channels/my%2Dchannel?view=citation',
+ });
+ });
+
+ it('should handle URLs with escaped slashes that decode to valid paths', () => {
+ // URL with %2F in team name decodes to a valid path structure
+ // After decoding: /team/evil/channels/general - the channel part is still valid
+ const result = parseCitationUrl('http://localhost:8066/team%2Fevil/channels/general?view=citation');
+
+ // The channel regex matches at the end of the decoded path
+ expect(result).toEqual({
+ entityType: 'CHANNEL',
+ entityId: 'general',
+ linkUrl: 'http://localhost:8066/team%2Fevil/channels/general?view=citation',
+ });
+ });
+
+ it('should reject URLs where escaped slashes break the expected pattern', () => {
+ // Escaped slash in the post ID itself - after decoding this creates an invalid path
+ const result = parseCitationUrl('http://localhost:8066/team/pl/abc%2F123?view=citation');
+
+ // The post ID pattern requires exactly 26 alphanumeric chars, abc/123 doesn't match
+ expect(result).toBeNull();
+ });
+ });
+ });
+
+ describe('processInlineEntities', () => {
+ const {processInlineEntities} = require('@components/markdown/transform');
+
+ // Valid 26-character post IDs for testing
+ const validPostId1 = 'hod5do9pc38q7e7ofcc7e3hzae';
+ const validPostId2 = 'abc123def456ghi789jkl012mn';
+
+ it('should not modify links without view=citation', () => {
+ const input = parser.parse(`[link text](http://example.com/team/pl/${validPostId1})`);
+ const result = processInlineEntities(input);
+
+ const walker = result.walker();
+ let foundLink = false;
+ let foundInlineEntity = false;
+ let e;
+ while ((e = walker.next())) {
+ if (e.node.type === 'link') {
+ foundLink = true;
+ expect(e.node.destination).toBe(`http://example.com/team/pl/${validPostId1}`);
+ }
+ if (e.node.type === 'inline_entity_link') {
+ foundInlineEntity = true;
+ }
+ }
+ expect(foundLink).toBe(true);
+ expect(foundInlineEntity).toBe(false);
+ });
+
+ it('should transform post citation links to inline_entity_link nodes', () => {
+ const input = parser.parse(`[permalink](http://localhost:8066/team/pl/${validPostId1}?view=citation)`);
+ const result = processInlineEntities(input);
+
+ const walker = result.walker();
+ let foundInlineEntity = false;
+ let foundLink = false;
+ let e;
+ while ((e = walker.next())) {
+ if (e.node.type === 'inline_entity_link') {
+ foundInlineEntity = true;
+ expect(e.node.entityType).toBe('POST');
+ expect(e.node.entityId).toBe(validPostId1);
+ expect(e.node.linkUrl).toBe(`http://localhost:8066/team/pl/${validPostId1}?view=citation`);
+ }
+ if (e.node.type === 'link') {
+ foundLink = true;
+ }
+ }
+ expect(foundInlineEntity).toBe(true);
+ expect(foundLink).toBe(false);
+ });
+
+ it('should transform channel citation links to inline_entity_link nodes', () => {
+ const input = parser.parse('[permalink](http://localhost:8066/team/channels/general?view=citation)');
+ const result = processInlineEntities(input);
+
+ const walker = result.walker();
+ let foundInlineEntity = false;
+ let foundLink = false;
+ let e;
+ while ((e = walker.next())) {
+ if (e.node.type === 'inline_entity_link') {
+ foundInlineEntity = true;
+ expect(e.node.entityType).toBe('CHANNEL');
+ expect(e.node.entityId).toBe('general');
+ }
+ if (e.node.type === 'link') {
+ foundLink = true;
+ }
+ }
+ expect(foundInlineEntity).toBe(true);
+ expect(foundLink).toBe(false);
+ });
+
+ it('should handle multiple citation links in one document', () => {
+ const input = parser.parse(`First [link](http://localhost:8066/team/pl/${validPostId1}?view=citation) and second [link](http://localhost:8066/team/pl/${validPostId2}?view=citation)`);
+ const result = processInlineEntities(input);
+
+ const walker = result.walker();
+ const entityLinks: any[] = [];
+ const regularLinks: any[] = [];
+ let e;
+ while ((e = walker.next())) {
+ if (e.node.type === 'inline_entity_link') {
+ entityLinks.push(e.node);
+ }
+ if (e.node.type === 'link') {
+ regularLinks.push(e.node);
+ }
+ }
+ expect(entityLinks.length).toBe(2);
+ expect(entityLinks[0].entityId).toBe(validPostId1);
+ expect(entityLinks[1].entityId).toBe(validPostId2);
+ expect(regularLinks.length).toBe(0);
+ });
+
+ it('should only process internal links when serverUrl is provided', () => {
+ const serverUrl = 'http://localhost:8066';
+ const input = parser.parse(`[external](http://external.com/team/pl/${validPostId1}?view=citation) and [internal](http://localhost:8066/team/pl/${validPostId2}?view=citation)`);
+ const result = processInlineEntities(input, serverUrl);
+
+ const walker = result.walker();
+ const entityLinks: any[] = [];
+ const regularLinks: any[] = [];
+ let e;
+ while ((e = walker.next())) {
+ // Only count on entering to avoid duplicates
+ if (!e.entering) {
+ continue;
+ }
+ if (e.node.type === 'inline_entity_link') {
+ entityLinks.push(e.node);
+ } else if (e.node.type === 'link') {
+ regularLinks.push(e.node);
+ }
+ }
+
+ // Only the internal link should be transformed
+ expect(entityLinks.length).toBe(1);
+ expect(entityLinks[0].entityId).toBe(validPostId2);
+
+ // Verify the internal link is NOT a regular link
+ expect(regularLinks.some((link) => link.destination.includes(validPostId2))).toBe(false);
+
+ // The external link should remain as a regular link
+ expect(regularLinks.length).toBe(1);
+ expect(regularLinks[0].destination).toBe(`http://external.com/team/pl/${validPostId1}?view=citation`);
+
+ // Verify the external link is NOT an inline_entity_link
+ expect(entityLinks.some((link) => link.entityId === validPostId1)).toBe(false);
+ });
+ });
});
// Testing and debugging functions
diff --git a/app/components/markdown/transform.ts b/app/components/markdown/transform.ts
index 20b4bfd8a..2fef81e48 100644
--- a/app/components/markdown/transform.ts
+++ b/app/components/markdown/transform.ts
@@ -2,10 +2,15 @@
// See LICENSE.txt for license information.
import {Node, type NodeType} from 'commonmark';
+import urlParse from 'url-parse';
+import {DeepLink} from '@constants';
+import {parseDeepLink} from '@utils/deep_link';
import {escapeRegex} from '@utils/markdown';
+import {safeDecodeURIComponent} from '@utils/url';
import type {HighlightWithoutNotificationKey, SearchPattern, UserMentionKey} from '@typings/global/markdown';
+import type {DeepLinkChannel, DeepLinkPermalink} from '@typings/launch';
/* eslint-disable no-underscore-dangle */
@@ -381,3 +386,115 @@ export function parseTaskLists(ast: Node) {
return ast;
}
+
+/**
+ * Parse a citation URL to extract entity type and identifier.
+ * Citation URLs must have view=citation as a query parameter.
+ * Uses parseDeepLink for URL structural parsing.
+ *
+ * URL patterns:
+ * - Post: /team/pl/postId?view=citation
+ * - Channel: /team/channels/channelName?view=citation
+ *
+ * @param url - The URL to parse
+ * @param serverUrl - Optional server URL to validate this is an internal link
+ * @returns Parsed citation info or null if not a valid citation URL
+ */
+export function parseCitationUrl(url: string, serverUrl?: string): {entityType: string; entityId: string; linkUrl: string} | null {
+ const decodedUrl = safeDecodeURIComponent(url);
+ const parsedUrl = urlParse(decodedUrl, true);
+
+ // Check if view=citation is a proper query parameter
+ if (parsedUrl.query.view !== 'citation') {
+ return null;
+ }
+
+ // If serverUrl is provided, validate this is an internal link
+ if (serverUrl) {
+ const parsedServerUrl = urlParse(serverUrl);
+ const normalizedServerHost = parsedServerUrl.hostname.toLowerCase();
+ const normalizedUrlHost = parsedUrl.hostname.toLowerCase();
+
+ if (normalizedUrlHost !== normalizedServerHost) {
+ return null;
+ }
+
+ if (parsedServerUrl.port && parsedUrl.port !== parsedServerUrl.port) {
+ return null;
+ }
+ }
+
+ // Use parseDeepLink to determine the entity type from the URL structure
+ const deepLink = parseDeepLink(decodedUrl);
+
+ switch (deepLink.type) {
+ case DeepLink.Permalink: {
+ const data = deepLink.data as DeepLinkPermalink;
+ return {
+ entityType: 'POST',
+ entityId: data.postId,
+ linkUrl: url,
+ };
+ }
+ case DeepLink.Channel: {
+ const data = deepLink.data as DeepLinkChannel;
+ return {
+ entityType: 'CHANNEL',
+ entityId: data.channelName,
+ linkUrl: url,
+ };
+ }
+ default:
+ return null;
+ }
+}
+
+/**
+ * Process markdown links with ?view=citation query parameter and
+ * replace them with inline_entity_link nodes that will be rendered as clickable link icons.
+ *
+ * This handles the new citation format:
+ * - [text](http://server/team/pl/postId?view=citation) -> post citation
+ * - [text](http://server/team/channels/channelName?view=citation) -> channel citation
+ * - [text](http://server/team?view=citation) -> team citation
+ *
+ * @param ast - The AST to process
+ * @param serverUrl - Optional server URL to validate internal links only
+ */
+export function processInlineEntities(ast: Node, serverUrl?: string) {
+ const walker = ast.walker();
+
+ let e;
+ while ((e = walker.next())) {
+ if (!e.entering) {
+ continue;
+ }
+
+ const node: any = e.node;
+
+ // Look for link nodes with citation query parameter
+ if (node.type !== 'link' || !node.destination) {
+ continue;
+ }
+
+ const parsed = parseCitationUrl(node.destination, serverUrl);
+ if (!parsed) {
+ continue;
+ }
+
+ // Create the inline entity link node
+ const entityNode: any = new Node('inline_entity_link' as any);
+ entityNode.entityType = parsed.entityType;
+ entityNode.entityId = parsed.entityId;
+ entityNode.linkUrl = parsed.linkUrl;
+
+ // Replace the link node with the entity node
+ node.insertBefore(entityNode);
+ node.unlink();
+
+ // Resume at the entity node
+ walker.resumeAt(entityNode, false);
+ }
+
+ return ast;
+}
diff --git a/app/products/agents/actions/remote/agents.ts b/app/products/agents/actions/remote/agents.ts
new file mode 100644
index 000000000..664eb467d
--- /dev/null
+++ b/app/products/agents/actions/remote/agents.ts
@@ -0,0 +1,21 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {type Agent} from '@agents/client/rest';
+
+import NetworkManager from '@managers/network_manager';
+import {getFullErrorMessage} from '@utils/errors';
+import {logError} from '@utils/log';
+
+export async function fetchAgents(
+ serverUrl: string,
+): Promise<{data?: Agent[]; error?: string}> {
+ try {
+ const client = NetworkManager.getClient(serverUrl);
+ const agents = await client.getAgents();
+ return {data: agents};
+ } catch (error) {
+ logError('[fetchAgents]', error);
+ return {error: getFullErrorMessage(error)};
+ }
+}
diff --git a/app/products/agents/actions/remote/agents_status.test.ts b/app/products/agents/actions/remote/agents_status.test.ts
new file mode 100644
index 000000000..24e1f5a55
--- /dev/null
+++ b/app/products/agents/actions/remote/agents_status.test.ts
@@ -0,0 +1,52 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {setAgentsConfig} from '@agents/store/agents_config';
+
+import NetworkManager from '@managers/network_manager';
+
+import {checkIsAgentsPluginEnabled} from './agents_status';
+
+jest.mock('@managers/network_manager');
+jest.mock('@agents/store/agents_config');
+jest.mock('@utils/log');
+
+describe('checkIsAgentsPluginEnabled', () => {
+ const serverUrl = 'https://server.example.com';
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('should set pluginEnabled to true when API returns available: true', async () => {
+ const getAgentsStatus = jest.fn().mockResolvedValue({available: true});
+ jest.mocked(NetworkManager.getClient).mockReturnValue({getAgentsStatus} as any);
+
+ const result = await checkIsAgentsPluginEnabled(serverUrl);
+
+ expect(getAgentsStatus).toHaveBeenCalled();
+ expect(setAgentsConfig).toHaveBeenCalledWith(serverUrl, {pluginEnabled: true});
+ expect(result).toEqual({data: true});
+ });
+
+ it('should set pluginEnabled to false when API returns available: false', async () => {
+ const getAgentsStatus = jest.fn().mockResolvedValue({available: false});
+ jest.mocked(NetworkManager.getClient).mockReturnValue({getAgentsStatus} as any);
+
+ const result = await checkIsAgentsPluginEnabled(serverUrl);
+
+ expect(setAgentsConfig).toHaveBeenCalledWith(serverUrl, {pluginEnabled: false});
+ expect(result).toEqual({data: false});
+ });
+
+ it('should not update config and return error when API call fails', async () => {
+ const error = new Error('network error');
+ const getAgentsStatus = jest.fn().mockRejectedValue(error);
+ jest.mocked(NetworkManager.getClient).mockReturnValue({getAgentsStatus} as any);
+
+ const result = await checkIsAgentsPluginEnabled(serverUrl);
+
+ expect(setAgentsConfig).not.toHaveBeenCalled();
+ expect(result).toEqual({error});
+ });
+});
diff --git a/app/products/agents/actions/remote/agents_status.ts b/app/products/agents/actions/remote/agents_status.ts
new file mode 100644
index 000000000..967983611
--- /dev/null
+++ b/app/products/agents/actions/remote/agents_status.ts
@@ -0,0 +1,19 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {setAgentsConfig} from '@agents/store/agents_config';
+
+import NetworkManager from '@managers/network_manager';
+import {logDebug} from '@utils/log';
+
+export async function checkIsAgentsPluginEnabled(serverUrl: string): Promise<{data?: boolean; error?: unknown}> {
+ try {
+ const client = NetworkManager.getClient(serverUrl);
+ const response = await client.getAgentsStatus();
+ setAgentsConfig(serverUrl, {pluginEnabled: response.available});
+ return {data: response.available};
+ } catch (error) {
+ logDebug('checkIsAgentsPluginEnabled', 'Failed to check agents status', error);
+ return {error};
+ }
+}
diff --git a/app/products/agents/actions/remote/channel_summary.test.ts b/app/products/agents/actions/remote/channel_summary.test.ts
new file mode 100644
index 000000000..b47755e3b
--- /dev/null
+++ b/app/products/agents/actions/remote/channel_summary.test.ts
@@ -0,0 +1,98 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {fetchMyChannel, switchToChannelById} from '@actions/remote/channel';
+import DatabaseManager from '@database/manager';
+import NetworkManager from '@managers/network_manager';
+import {getMyChannel} from '@queries/servers/channel';
+
+import {requestChannelSummary} from './channel_summary';
+
+jest.mock('@actions/remote/channel');
+jest.mock('@managers/network_manager');
+jest.mock('@database/manager', () => ({
+ getServerDatabaseAndOperator: jest.fn(),
+}));
+jest.mock('@queries/servers/channel');
+
+describe('requestChannelSummary', () => {
+ const serverUrl = 'https://server.example.com';
+ const channelId = 'channel-id';
+ const botUsername = 'ai-bot';
+ const analysisType = 'summarize_channel';
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('calls client, ensures channel membership, and switches channel on success', async () => {
+ const doChannelAnalysis = jest.fn().mockResolvedValue({postid: 'post-id', channelid: 'dm-id'});
+ jest.mocked(NetworkManager.getClient).mockReturnValue({doChannelAnalysis} as any);
+ jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({database: {}} as any);
+ jest.mocked(getMyChannel).mockResolvedValue({id: channelId} as any);
+
+ const result = await requestChannelSummary(serverUrl, channelId, analysisType, botUsername, {days: 7});
+
+ expect(getMyChannel).toHaveBeenCalledWith({}, channelId);
+ expect(fetchMyChannel).not.toHaveBeenCalled();
+ expect(doChannelAnalysis).toHaveBeenCalledWith(channelId, analysisType, botUsername, {days: 7});
+ expect(switchToChannelById).toHaveBeenCalledWith(serverUrl, 'dm-id');
+ expect(result.error).toBeUndefined();
+ expect(result.data).toEqual({postid: 'post-id', channelid: 'dm-id'});
+ });
+
+ it('fetches channel membership if it does not exist in database before requesting', async () => {
+ const doChannelAnalysis = jest.fn().mockResolvedValue({postid: 'post-id', channelid: 'dm-id'});
+ jest.mocked(NetworkManager.getClient).mockReturnValue({doChannelAnalysis} as any);
+ jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({database: {}} as any);
+ jest.mocked(getMyChannel).mockResolvedValue(undefined);
+ jest.mocked(fetchMyChannel).mockResolvedValue({channels: [], memberships: []});
+
+ const result = await requestChannelSummary(serverUrl, channelId, analysisType, botUsername);
+
+ expect(getMyChannel).toHaveBeenCalledWith({}, channelId);
+ expect(fetchMyChannel).toHaveBeenCalledWith(serverUrl, '', channelId);
+ expect(doChannelAnalysis).toHaveBeenCalled();
+ expect(switchToChannelById).toHaveBeenCalledWith(serverUrl, 'dm-id');
+ expect(result.error).toBeUndefined();
+ expect(result.data).toEqual({postid: 'post-id', channelid: 'dm-id'});
+ });
+
+ it('returns error if fetching channel membership fails before requesting', async () => {
+ const doChannelAnalysis = jest.fn();
+ jest.mocked(NetworkManager.getClient).mockReturnValue({doChannelAnalysis} as any);
+ jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({database: {}} as any);
+ jest.mocked(getMyChannel).mockResolvedValue(undefined);
+ jest.mocked(fetchMyChannel).mockResolvedValue({error: 'Failed to fetch channel'});
+
+ const result = await requestChannelSummary(serverUrl, channelId, analysisType, botUsername);
+
+ expect(fetchMyChannel).toHaveBeenCalledWith(serverUrl, '', channelId);
+ expect(doChannelAnalysis).not.toHaveBeenCalled();
+ expect(switchToChannelById).not.toHaveBeenCalled();
+ expect(result.error).toBe('Failed to fetch channel');
+ });
+
+ it('returns error when response is invalid', async () => {
+ const doChannelAnalysis = jest.fn().mockResolvedValue({});
+ jest.mocked(NetworkManager.getClient).mockReturnValue({doChannelAnalysis} as any);
+ jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({database: {}} as any);
+ jest.mocked(getMyChannel).mockResolvedValue({id: channelId} as any);
+
+ const result = await requestChannelSummary(serverUrl, channelId, analysisType, botUsername);
+
+ expect(result.error).toBe('Invalid response from server');
+ });
+
+ it('surfaces errors from client', async () => {
+ const doChannelAnalysis = jest.fn().mockRejectedValue(new Error('boom'));
+ jest.mocked(NetworkManager.getClient).mockReturnValue({doChannelAnalysis} as any);
+ jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({database: {}} as any);
+ jest.mocked(getMyChannel).mockResolvedValue({id: channelId} as any);
+
+ const result = await requestChannelSummary(serverUrl, channelId, analysisType, botUsername);
+
+ expect(result.error).toBe('boom');
+ });
+});
+
diff --git a/app/products/agents/actions/remote/channel_summary.ts b/app/products/agents/actions/remote/channel_summary.ts
new file mode 100644
index 000000000..fbb97b7c4
--- /dev/null
+++ b/app/products/agents/actions/remote/channel_summary.ts
@@ -0,0 +1,50 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {fetchMyChannel, switchToChannelById} from '@actions/remote/channel';
+import DatabaseManager from '@database/manager';
+import NetworkManager from '@managers/network_manager';
+import {getMyChannel} from '@queries/servers/channel';
+import {getFullErrorMessage} from '@utils/errors';
+import {logError} from '@utils/log';
+
+import type {ChannelAnalysisOptions, ChannelAnalysisResponse} from '@agents/types/api';
+
+export async function requestChannelSummary(
+ serverUrl: string,
+ channelId: string,
+ analysisType: string,
+ botUsername: string,
+ options?: ChannelAnalysisOptions,
+): Promise<{data?: ChannelAnalysisResponse; error?: string}> {
+ try {
+ // Ensure channel exists in database and user has membership before requesting
+ // This is critical for offline compatibility - switchToChannelById expects
+ // the channel to already exist in the database
+ const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
+ const myChannel = await getMyChannel(database, channelId);
+ if (!myChannel) {
+ // Channel doesn't exist or user doesn't have membership - fetch and persist it
+ // Use empty string for teamId - fetchMyChannel will use channel.team_id if available
+ const channelResult = await fetchMyChannel(serverUrl, '', channelId);
+ if (channelResult.error) {
+ return {error: getFullErrorMessage(channelResult.error)};
+ }
+ }
+
+ const client = NetworkManager.getClient(serverUrl);
+ const result = await client.doChannelAnalysis(channelId, analysisType, botUsername, options);
+
+ if (!result?.postid || !result?.channelid) {
+ return {error: 'Invalid response from server'};
+ }
+
+ await switchToChannelById(serverUrl, result.channelid);
+
+ return {data: result};
+ } catch (error) {
+ logError('[requestChannelSummary]', error);
+ return {error: getFullErrorMessage(error)};
+ }
+}
+
diff --git a/app/products/agents/client/rest.ts b/app/products/agents/client/rest.ts
index 1747ddb77..c40ad6543 100644
--- a/app/products/agents/client/rest.ts
+++ b/app/products/agents/client/rest.ts
@@ -1,11 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import type {Agent, AgentsResponse, AgentsStatusResponse, ChannelAnalysisOptions, ChannelAnalysisResponse} from '@agents/types/api';
+
+export type {Agent};
+
export interface ClientAgentsMix {
getAgentsRoute: () => string;
+ getAgents: () => Promise;
stopGeneration: (postId: string) => Promise;
regenerateResponse: (postId: string) => Promise;
+ doChannelAnalysis: (
+ channelId: string,
+ analysisType: string,
+ botUsername: string,
+ options?: ChannelAnalysisOptions,
+ ) => Promise;
submitToolApproval: (postId: string, acceptedToolIds: string[]) => Promise;
+ getAgentsStatus: () => Promise;
}
const ClientAgents = (superclass: any) => class extends superclass {
@@ -13,6 +25,19 @@ const ClientAgents = (superclass: any) => class extends superclass {
return '/plugins/mattermost-ai';
};
+ getAgents = async (): Promise => {
+ const response = await this.doFetch(
+ `${this.urlVersion}/agents`,
+ {method: 'get'},
+ );
+
+ // Handle both array response and wrapped response
+ if (Array.isArray(response)) {
+ return response;
+ }
+ return (response as AgentsResponse).agents || [];
+ };
+
stopGeneration = async (postId: string) => {
return this.doFetch(
`${this.getAgentsRoute()}/post/${postId}/stop`,
@@ -27,6 +52,29 @@ const ClientAgents = (superclass: any) => class extends superclass {
);
};
+ doChannelAnalysis = async (
+ channelId: string,
+ analysisType: string,
+ botUsername: string,
+ options?: ChannelAnalysisOptions,
+ ): Promise => {
+ const {since, until, days, prompt, unreads_only} = options || {};
+ return this.doFetch(
+ `${this.getAgentsRoute()}/channel/${channelId}/analyze?botUsername=${encodeURIComponent(botUsername)}`,
+ {
+ method: 'post',
+ body: {
+ analysis_type: analysisType,
+ since,
+ until,
+ days,
+ prompt,
+ unreads_only,
+ },
+ },
+ );
+ };
+
submitToolApproval = async (postId: string, acceptedToolIds: string[]) => {
return this.doFetch(
`${this.getAgentsRoute()}/post/${postId}/tool_call`,
@@ -36,6 +84,13 @@ const ClientAgents = (superclass: any) => class extends superclass {
},
);
};
+
+ getAgentsStatus = async (): Promise => {
+ return this.doFetch(
+ `${this.urlVersion}/agents/status`,
+ {method: 'get'},
+ );
+ };
};
export default ClientAgents;
diff --git a/app/products/agents/components/channel_summary_sheet/agent_item.tsx b/app/products/agents/components/channel_summary_sheet/agent_item.tsx
new file mode 100644
index 000000000..4f6f8a0cf
--- /dev/null
+++ b/app/products/agents/components/channel_summary_sheet/agent_item.tsx
@@ -0,0 +1,108 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {type Agent} from '@agents/client/rest';
+import React, {useCallback, useMemo} from 'react';
+import {Text, TouchableOpacity, View} from 'react-native';
+
+import CompassIcon from '@components/compass_icon';
+import {ExpoImageAnimated} from '@components/expo_image';
+import {ACCOUNT_OUTLINE_IMAGE} from '@constants/profile';
+import {useTheme} from '@context/theme';
+import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
+import {typography} from '@utils/typography';
+
+const AVATAR_SIZE = 32;
+
+const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
+ agentRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingVertical: 16,
+ },
+ agentInfo: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ flex: 1,
+ },
+ agentAvatar: {
+ width: AVATAR_SIZE,
+ height: AVATAR_SIZE,
+ borderRadius: AVATAR_SIZE / 2,
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
+ },
+ agentName: {
+ color: theme.centerChannelColor,
+ ...typography('Body', 200, 'Regular'),
+ },
+}));
+
+type AgentItemProps = {
+ agent: Agent;
+ profileImageUrl?: string;
+ currentAgentUsername: string;
+ onSelect: (agent: Agent) => void;
+};
+
+const AgentItem = React.memo(({agent, profileImageUrl, currentAgentUsername, onSelect}: AgentItemProps) => {
+ const theme = useTheme();
+ const styles = useMemo(() => getStyleSheet(theme), [theme]);
+
+ const handlePress = useCallback(() => {
+ onSelect(agent);
+ }, [onSelect, agent]);
+
+ const imageStyle = useMemo(() => ({
+ borderRadius: AVATAR_SIZE / 2,
+ backgroundColor: theme.centerChannelBg,
+ height: AVATAR_SIZE,
+ width: AVATAR_SIZE,
+ }), [theme.centerChannelBg]);
+
+ const iconStyle = useMemo(() => ({
+ color: changeOpacity(theme.centerChannelColor, 0.48),
+ }), [theme.centerChannelColor]);
+
+ const cacheId = agent.id ? `user-${agent.id}` : undefined;
+
+ return (
+
+
+ {profileImageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+ {agent.displayName || agent.username}
+
+
+ {currentAgentUsername === agent.username && (
+
+ )}
+
+ );
+});
+AgentItem.displayName = 'AgentItem';
+
+export default AgentItem;
diff --git a/app/products/agents/components/channel_summary_sheet/agent_selector_panel.tsx b/app/products/agents/components/channel_summary_sheet/agent_selector_panel.tsx
new file mode 100644
index 000000000..b9d7e2f6b
--- /dev/null
+++ b/app/products/agents/components/channel_summary_sheet/agent_selector_panel.tsx
@@ -0,0 +1,143 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {type Agent} from '@agents/client/rest';
+import React, {useCallback, useMemo} from 'react';
+import {FlatList, type ListRenderItemInfo, TouchableOpacity, View} from 'react-native';
+
+import {buildAbsoluteUrl} from '@actions/remote/file';
+import {buildProfileImageUrl} from '@actions/remote/user';
+import CompassIcon from '@components/compass_icon';
+import FormattedText from '@components/formatted_text';
+import {useServerUrl} from '@context/server';
+import {useTheme} from '@context/theme';
+import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
+import {typography} from '@utils/typography';
+
+import AgentItem from './agent_item';
+
+type Props = {
+ agents: Agent[];
+ currentAgentUsername: string;
+ onSelectAgent: (agent: Agent) => void;
+ onBack: () => void;
+};
+
+const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
+ container: {
+ flex: 1,
+ },
+ headerRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 12,
+ marginBottom: 8,
+ },
+ backButton: {
+ marginRight: 12,
+ },
+ headerTitle: {
+ color: theme.centerChannelColor,
+ ...typography('Heading', 300, 'SemiBold'),
+ },
+ divider: {
+ height: 1,
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
+ },
+ emptyContainer: {
+ paddingVertical: 40,
+ alignItems: 'center',
+ },
+ emptyText: {
+ color: changeOpacity(theme.centerChannelColor, 0.56),
+ ...typography('Body', 200, 'Regular'),
+ },
+}));
+
+const AgentSelectorPanel = ({
+ agents,
+ currentAgentUsername,
+ onSelectAgent,
+ onBack,
+}: Props) => {
+ const theme = useTheme();
+ const serverUrl = useServerUrl();
+ const styles = getStyleSheet(theme);
+
+ // Build profile image URLs for all agents
+ const agentImageUrls = useMemo(() => {
+ const urls: Record = {};
+ for (const agent of agents) {
+ if (agent.id) {
+ const relativePath = buildProfileImageUrl(serverUrl, agent.id);
+ urls[agent.id] = relativePath ? buildAbsoluteUrl(serverUrl, relativePath) : undefined;
+ }
+ }
+ return urls;
+ }, [agents, serverUrl]);
+
+ const keyExtractor = useCallback((agent: Agent) => agent.id || agent.username, []);
+
+ const renderItem = useCallback(({item: agent}: ListRenderItemInfo) => {
+ const profileImageUrl = agent.id ? agentImageUrls[agent.id] : undefined;
+ return (
+
+ );
+ }, [agentImageUrls, currentAgentUsername, onSelectAgent]);
+
+ const renderSeparator = useCallback(() => (
+
+ ), [styles.divider]);
+
+ const renderEmpty = useCallback(() => (
+
+
+
+ ), [styles.emptyContainer, styles.emptyText]);
+
+ const renderHeader = useCallback(() => (
+
+
+
+
+
+
+ ), [onBack, styles.backButton, styles.headerRow, styles.headerTitle, theme.centerChannelColor]);
+
+ return (
+
+
+
+ );
+};
+
+export default AgentSelectorPanel;
diff --git a/app/products/agents/components/channel_summary_sheet/date_range_picker.tsx b/app/products/agents/components/channel_summary_sheet/date_range_picker.tsx
new file mode 100644
index 000000000..51a01b1f1
--- /dev/null
+++ b/app/products/agents/components/channel_summary_sheet/date_range_picker.tsx
@@ -0,0 +1,289 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import DateTimePicker, {type DateTimePickerEvent} from '@react-native-community/datetimepicker';
+import React, {useCallback, useMemo, useState} from 'react';
+import {defineMessages, useIntl, type MessageDescriptor} from 'react-intl';
+import {Platform, TouchableOpacity, View} from 'react-native';
+import tinyColor from 'tinycolor2';
+
+import Button from '@components/button';
+import CompassIcon from '@components/compass_icon';
+import FormattedDate, {type FormattedDateFormat} from '@components/formatted_date';
+import FormattedText from '@components/formatted_text';
+import {useTheme} from '@context/theme';
+import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
+import {typography} from '@utils/typography';
+
+type Props = {
+ onSubmit: (since: Date, until: Date) => void;
+ onCancel: () => void;
+};
+
+const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
+ container: {
+ paddingBottom: 8,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 8,
+ },
+ backButton: {
+ marginRight: 12,
+ },
+ headerTitle: {
+ color: theme.centerChannelColor,
+ ...typography('Heading', 300, 'SemiBold'),
+ },
+ label: {
+ color: changeOpacity(theme.centerChannelColor, 0.72),
+ marginBottom: 4,
+ marginTop: 8,
+ ...typography('Body', 75, 'SemiBold'),
+ },
+ dateBox: {
+ borderWidth: 1,
+ borderColor: changeOpacity(theme.centerChannelColor, 0.16),
+ borderRadius: 4,
+ padding: 12,
+ },
+ dateBoxActive: {
+ borderWidth: 2,
+ borderColor: theme.buttonBg,
+ },
+ dateText: {
+ color: theme.centerChannelColor,
+ ...typography('Body', 200, 'Regular'),
+ },
+ datePlaceholder: {
+ color: changeOpacity(theme.centerChannelColor, 0.56),
+ ...typography('Body', 200, 'Regular'),
+ },
+ error: {
+ marginTop: 8,
+ color: theme.errorTextColor,
+ ...typography('Body', 75, 'SemiBold'),
+ },
+ footer: {
+ marginTop: 24,
+ },
+}));
+
+const DATE_FORMAT: FormattedDateFormat = {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+};
+
+const dateMessages = defineMessages({
+ sinceLabel: {id: 'agents.channel_summary.since', defaultMessage: 'Start'},
+ sincePlaceholder: {id: 'agents.channel_summary.since_placeholder', defaultMessage: 'Select date'},
+ untilLabel: {id: 'agents.channel_summary.until', defaultMessage: 'End'},
+ untilPlaceholder: {id: 'agents.channel_summary.until_placeholder', defaultMessage: 'Select date'},
+});
+
+type PickerTarget = 'since' | 'until' | undefined;
+
+const BACK_BUTTON_HIT_SLOP = {top: 16, bottom: 16, left: 16, right: 16};
+
+type DateInputFieldProps = {
+ label: MessageDescriptor;
+ date: Date | undefined;
+ placeholder: MessageDescriptor;
+ onPress: () => void;
+ testID: string;
+ styles: ReturnType;
+ isActive: boolean;
+};
+
+const DateInputField = ({label, date, placeholder, onPress, testID, styles, isActive}: DateInputFieldProps) => (
+ <>
+
+
+ {date ? (
+
+ ) : (
+
+ )}
+
+ >
+);
+
+const DateRangePicker = ({onSubmit, onCancel}: Props) => {
+ const intl = useIntl();
+ const theme = useTheme();
+ const styles = getStyleSheet(theme);
+
+ const [since, setSince] = useState(() => {
+ const d = new Date();
+ d.setDate(d.getDate() - 7);
+ return d;
+ });
+ const [until, setUntil] = useState(() => new Date());
+ const [pickerTarget, setPickerTarget] = useState();
+ const [showError, setShowError] = useState(false);
+
+ const dateIsValid = useMemo(() => {
+ if (!since || !until) {
+ return false;
+ }
+ return since.getTime() <= until.getTime();
+ }, [since, until]);
+
+ const openSincePicker = useCallback(() => {
+ if (!since) {
+ const today = new Date();
+ setSince(until && until < today ? until : today);
+ }
+ setPickerTarget('since');
+ }, [since, until]);
+
+ const openUntilPicker = useCallback(() => {
+ if (!until) {
+ const today = new Date();
+ setUntil(since && since > today ? since : today);
+ }
+ setPickerTarget('until');
+ }, [until, since]);
+
+ const onChangeDate = useCallback((_: DateTimePickerEvent, date?: Date) => {
+ if (Platform.OS === 'ios') {
+ // iOS uses an inline picker that can stay visible
+ const currentTarget = pickerTarget;
+
+ if (!currentTarget || !date) {
+ setPickerTarget(undefined);
+ return;
+ }
+
+ if (currentTarget === 'since') {
+ setSince(date);
+ } else {
+ setUntil(date);
+ }
+
+ // Don't close picker on iOS - user can continue adjusting
+ } else {
+ // Android: Set date inside functional updater to batch both state updates,
+ // preventing React re-renders from causing the picker to reopen.
+ setPickerTarget((currentTarget) => {
+ if (currentTarget && date) {
+ if (currentTarget === 'since') {
+ setSince(date);
+ } else {
+ setUntil(date);
+ }
+ }
+ return undefined; // Always close on Android (modal auto-dismisses)
+ });
+ }
+ }, [pickerTarget]);
+
+ const handleSubmit = () => {
+ setShowError(false);
+ if (!since || !until || !dateIsValid) {
+ setShowError(true);
+ return;
+ }
+ onSubmit(since, until);
+ };
+
+ return (
+
+ {/* Header with back button */}
+
+
+
+
+
+
+
+ {/* Start Date */}
+
+
+ {/* End Date */}
+
+
+ {showError && (
+
+ )}
+
+ {/* Submit Button */}
+
+
+
+
+ {/* Date Picker */}
+ {pickerTarget && (
+
+ )}
+
+ );
+};
+
+export default DateRangePicker;
diff --git a/app/products/agents/components/channel_summary_sheet/index.tsx b/app/products/agents/components/channel_summary_sheet/index.tsx
new file mode 100644
index 000000000..754f2404e
--- /dev/null
+++ b/app/products/agents/components/channel_summary_sheet/index.tsx
@@ -0,0 +1,384 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {fetchAgents} from '@agents/actions/remote/agents';
+import {requestChannelSummary} from '@agents/actions/remote/channel_summary';
+import {type Agent} from '@agents/client/rest';
+import {AGENT_ANALYSIS_SUMMARY} from '@agents/constants';
+import React, {useCallback, useEffect, useState} from 'react';
+import {defineMessages, useIntl, type MessageDescriptor} from 'react-intl';
+import {Alert, ScrollView, Text, TouchableOpacity, View} from 'react-native';
+
+import CompassIcon from '@components/compass_icon';
+import FloatingTextInput from '@components/floating_input/floating_text_input_label';
+import FormattedText from '@components/formatted_text';
+import Loading from '@components/loading';
+import OptionItem from '@components/option_item';
+import {useServerUrl} from '@context/server';
+import {useTheme} from '@context/theme';
+import {usePreventDoubleTap} from '@hooks/utils';
+import {dismissBottomSheet} from '@screens/navigation';
+import {getErrorMessage} from '@utils/errors';
+import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
+import {typography} from '@utils/typography';
+
+import AgentSelectorPanel from './agent_selector_panel';
+import DateRangePicker from './date_range_picker';
+
+type SummaryOptionId = 'unreads' | '7d' | '14d' | 'custom';
+
+type SummaryOption = {
+ id: SummaryOptionId;
+ message: MessageDescriptor;
+ days?: number;
+ showChevron?: boolean;
+};
+
+const messages = defineMessages({
+ unreads: {id: 'agents.channel_summary.option.unreads', defaultMessage: 'Summarize unreads'},
+ sevenDays: {id: 'agents.channel_summary.option.7d', defaultMessage: 'Summarize last 7 days'},
+ fourteenDays: {id: 'agents.channel_summary.option.14d', defaultMessage: 'Summarize last 14 days'},
+ custom: {id: 'agents.channel_summary.option.custom', defaultMessage: 'Select date range to summarize'},
+});
+
+const SUMMARY_OPTIONS: SummaryOption[] = [
+ {id: 'unreads', message: messages.unreads},
+ {id: '7d', days: 7, message: messages.sevenDays},
+ {id: '14d', days: 14, message: messages.fourteenDays},
+ {id: 'custom', message: messages.custom, showChevron: true},
+];
+
+type Props = {
+ channelId: string;
+};
+
+const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
+ headerSection: {
+ gap: 4,
+ },
+ agentRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingVertical: 8,
+ },
+ agentLabel: {
+ color: theme.centerChannelColor,
+ ...typography('Body', 200, 'Regular'),
+ },
+ agentSelector: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ },
+ agentName: {
+ color: changeOpacity(theme.centerChannelColor, 0.56),
+ ...typography('Body', 100, 'Regular'),
+ },
+ promptWrapper: {
+ paddingTop: 4,
+ },
+ sendButton: {
+ width: 44,
+ height: 32,
+ borderRadius: 4,
+ backgroundColor: theme.buttonBg,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ sendButtonDisabled: {
+ backgroundColor: changeOpacity(theme.buttonBg, 0.5),
+ },
+ optionsContainer: {
+ paddingVertical: 8,
+ },
+ loadingOverlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: changeOpacity(theme.centerChannelBg, 0.7),
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+}));
+
+const ChannelSummarySheet = ({channelId}: Props) => {
+ const intl = useIntl();
+ const theme = useTheme();
+ const serverUrl = useServerUrl();
+ const styles = getStyleSheet(theme);
+
+ const [customPrompt, setCustomPrompt] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [showDatePicker, setShowDatePicker] = useState(false);
+ const [showAgentSelector, setShowAgentSelector] = useState(false);
+ const [agents, setAgents] = useState([]);
+ const [selectedAgent, setSelectedAgent] = useState(null);
+ const [loadingAgents, setLoadingAgents] = useState(true);
+
+ // Fetch agents on mount and set default to first agent
+ useEffect(() => {
+ const loadAgents = async () => {
+ setLoadingAgents(true);
+ const result = await fetchAgents(serverUrl);
+ if (result.data && result.data.length > 0) {
+ setAgents(result.data);
+ setSelectedAgent(result.data[0]);
+ }
+ setLoadingAgents(false);
+ };
+ loadAgents();
+ }, [serverUrl]);
+
+ const handleOptionPress = useCallback(async (optionId: string | boolean) => {
+ if (submitting) {
+ return;
+ }
+
+ const option = SUMMARY_OPTIONS.find((o) => o.id === optionId);
+ if (!option) {
+ return;
+ }
+
+ if (option.id === 'custom') {
+ setShowDatePicker(true);
+ return;
+ }
+
+ // Validate selectedAgent before setting submitting state to avoid stuck loading UI
+ if (!selectedAgent) {
+ return;
+ }
+
+ setSubmitting(true);
+ const options: Record = {};
+
+ if (option.days) {
+ options.days = option.days;
+ }
+
+ if (option.id === 'unreads') {
+ options.unreads_only = true;
+ }
+
+ if (customPrompt.trim()) {
+ options.prompt = customPrompt.trim();
+ }
+
+ const {error} = await requestChannelSummary(
+ serverUrl,
+ channelId,
+ AGENT_ANALYSIS_SUMMARY,
+ selectedAgent.username,
+ options,
+ );
+
+ if (error) {
+ setSubmitting(false);
+ Alert.alert(
+ intl.formatMessage({id: 'agents.channel_summary.error_title', defaultMessage: 'Unable to start summary'}),
+ getErrorMessage(error, intl),
+ );
+ return;
+ }
+
+ dismissBottomSheet();
+ }, [serverUrl, channelId, selectedAgent, customPrompt, intl, submitting]);
+
+ const handleAgentSelectorOpen = useCallback(() => {
+ setShowAgentSelector(true);
+ }, []);
+
+ const handleAgentSelectorBack = useCallback(() => {
+ setShowAgentSelector(false);
+ }, []);
+
+ const handleAgentSelect = useCallback((agent: Agent) => {
+ setSelectedAgent(agent);
+ setShowAgentSelector(false);
+ }, []);
+
+ const handleCustomPromptSubmit = useCallback(async () => {
+ if (!customPrompt.trim() || !selectedAgent) {
+ return;
+ }
+
+ setSubmitting(true);
+ const options: Record = {
+ prompt: customPrompt.trim(),
+ };
+
+ const {error} = await requestChannelSummary(
+ serverUrl,
+ channelId,
+ AGENT_ANALYSIS_SUMMARY,
+ selectedAgent.username,
+ options,
+ );
+
+ if (error) {
+ setSubmitting(false);
+ Alert.alert(
+ intl.formatMessage({id: 'agents.channel_summary.error_title', defaultMessage: 'Unable to start summary'}),
+ getErrorMessage(error, intl),
+ );
+ return;
+ }
+
+ dismissBottomSheet();
+ }, [serverUrl, channelId, selectedAgent, customPrompt, intl]);
+
+ const handleDateRangeSubmit = useCallback(async (since: Date, until: Date) => {
+ if (!selectedAgent) {
+ return;
+ }
+
+ setShowDatePicker(false);
+ setSubmitting(true);
+
+ // Normalize to UTC start/end of day to avoid missing data due to timezone conversion
+ const sinceUtc = new Date(Date.UTC(since.getFullYear(), since.getMonth(), since.getDate(), 0, 0, 0));
+ const untilUtc = new Date(Date.UTC(until.getFullYear(), until.getMonth(), until.getDate(), 23, 59, 59));
+
+ const options: Record = {
+ since: sinceUtc.toISOString(),
+ until: untilUtc.toISOString(),
+ };
+
+ if (customPrompt.trim()) {
+ options.prompt = customPrompt.trim();
+ }
+
+ const {error} = await requestChannelSummary(
+ serverUrl,
+ channelId,
+ AGENT_ANALYSIS_SUMMARY,
+ selectedAgent.username,
+ options,
+ );
+
+ if (error) {
+ setSubmitting(false);
+ Alert.alert(
+ intl.formatMessage({id: 'agents.channel_summary.error_title', defaultMessage: 'Unable to start summary'}),
+ getErrorMessage(error, intl),
+ );
+ return;
+ }
+
+ dismissBottomSheet();
+ }, [serverUrl, channelId, selectedAgent, customPrompt, intl]);
+
+ const handleCustomPromptSubmitDebounced = usePreventDoubleTap(handleCustomPromptSubmit);
+ const handleDateRangeSubmitDebounced = usePreventDoubleTap(handleDateRangeSubmit);
+
+ if (showAgentSelector) {
+ return (
+
+ );
+ }
+
+ if (showDatePicker) {
+ return (
+ setShowDatePicker(false)}
+ />
+ );
+ }
+
+ const selectedAgentDisplayName = selectedAgent?.displayName || selectedAgent?.username || '';
+
+ return (
+
+ {/* Header Section - Agent selector + Prompt input */}
+
+
+
+
+ {loadingAgents ? (
+
+ ) : (
+ <>
+ {selectedAgentDisplayName}
+
+ >
+ )}
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+ {/* Summary Options */}
+
+ {SUMMARY_OPTIONS.map((option) => (
+
+ ))}
+
+
+ {submitting && (
+
+
+
+ )}
+
+ );
+};
+
+export default ChannelSummarySheet;
diff --git a/app/products/agents/constants.ts b/app/products/agents/constants.ts
index 3ad66f30f..2452848a7 100644
--- a/app/products/agents/constants.ts
+++ b/app/products/agents/constants.ts
@@ -31,3 +31,6 @@ export const CONTROL_SIGNALS = {
TOOL_CALL: 'tool_call',
ANNOTATIONS: 'annotations',
} as const;
+
+export const DEFAULT_AGENT_BOT_USERNAME = 'ai-bot';
+export const AGENT_ANALYSIS_SUMMARY = 'summarize_channel';
diff --git a/app/products/agents/store/agents_config.test.ts b/app/products/agents/store/agents_config.test.ts
new file mode 100644
index 000000000..1775ee504
--- /dev/null
+++ b/app/products/agents/store/agents_config.test.ts
@@ -0,0 +1,108 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {act, renderHook} from '@testing-library/react-hooks';
+
+import type {AgentsConfigState} from './agents_config';
+
+describe('AgentsConfigStore', () => {
+ let getAgentsConfig: typeof import('./agents_config').getAgentsConfig;
+ let setAgentsConfig: typeof import('./agents_config').setAgentsConfig;
+ let observeAgentsConfig: typeof import('./agents_config').observeAgentsConfig;
+
+ beforeEach(() => {
+ jest.resetModules();
+ const store = require('./agents_config');
+ getAgentsConfig = store.getAgentsConfig;
+ setAgentsConfig = store.setAgentsConfig;
+ observeAgentsConfig = store.observeAgentsConfig;
+ });
+
+ afterEach(() => {
+ jest.resetModules();
+ });
+
+ describe('getAgentsConfig / setAgentsConfig', () => {
+ it('should return default config for a new server URL', () => {
+ const config = getAgentsConfig('server1');
+ expect(config).toEqual({pluginEnabled: false});
+ });
+
+ it('should update config when setAgentsConfig is called', () => {
+ setAgentsConfig('server1', {pluginEnabled: true});
+
+ const config = getAgentsConfig('server1');
+ expect(config).toEqual({pluginEnabled: true});
+ });
+
+ it('should merge partial config updates', () => {
+ setAgentsConfig('server1', {pluginEnabled: true});
+ const configAfterFirst = getAgentsConfig('server1');
+ expect(configAfterFirst.pluginEnabled).toBe(true);
+
+ // Setting again with same partial should merge over existing values
+ setAgentsConfig('server1', {pluginEnabled: false});
+ const configAfterSecond = getAgentsConfig('server1');
+ expect(configAfterSecond.pluginEnabled).toBe(false);
+ });
+
+ it('should maintain separate configs per server URL', () => {
+ setAgentsConfig('server1', {pluginEnabled: true});
+ setAgentsConfig('server2', {pluginEnabled: false});
+
+ expect(getAgentsConfig('server1')).toEqual({pluginEnabled: true});
+ expect(getAgentsConfig('server2')).toEqual({pluginEnabled: false});
+ });
+ });
+
+ describe('observeAgentsConfig', () => {
+ it('should emit default config on initial subscribe', () => {
+ const receivedConfigs: AgentsConfigState[] = [];
+
+ const subscription = observeAgentsConfig('server1').subscribe((config) => {
+ receivedConfigs.push(config);
+ });
+
+ expect(receivedConfigs).toHaveLength(1);
+ expect(receivedConfigs[0]).toEqual({pluginEnabled: false});
+
+ subscription.unsubscribe();
+ });
+
+ it('should emit updated config after setAgentsConfig', () => {
+ const receivedConfigs: AgentsConfigState[] = [];
+
+ const subscription = observeAgentsConfig('server1').subscribe((config) => {
+ receivedConfigs.push(config);
+ });
+
+ setAgentsConfig('server1', {pluginEnabled: true});
+
+ expect(receivedConfigs).toHaveLength(2);
+ expect(receivedConfigs[1]).toEqual({pluginEnabled: true});
+
+ subscription.unsubscribe();
+ });
+ });
+});
+
+// Hook tests use static imports to avoid React instance mismatch from jest.resetModules().
+// Each test uses a unique server URL for isolation (each creates a fresh BehaviorSubject).
+describe('useAgentsConfig', () => {
+ const {useAgentsConfig, setAgentsConfig} = require('./agents_config') as typeof import('./agents_config');
+
+ it('should return default config initially', () => {
+ const {result} = renderHook(() => useAgentsConfig('hook-test-default'));
+ expect(result.current).toEqual({pluginEnabled: false});
+ });
+
+ it('should update when config changes', () => {
+ const {result} = renderHook(() => useAgentsConfig('hook-test-update'));
+ expect(result.current).toEqual({pluginEnabled: false});
+
+ act(() => {
+ setAgentsConfig('hook-test-update', {pluginEnabled: true});
+ });
+ expect(result.current).toEqual({pluginEnabled: true});
+ });
+});
diff --git a/app/products/agents/store/agents_config.ts b/app/products/agents/store/agents_config.ts
new file mode 100644
index 000000000..24b909be7
--- /dev/null
+++ b/app/products/agents/store/agents_config.ts
@@ -0,0 +1,54 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {useEffect, useState} from 'react';
+import {BehaviorSubject} from 'rxjs';
+
+export type AgentsConfigState = {
+ pluginEnabled: boolean;
+};
+
+const DefaultAgentsConfig: AgentsConfigState = {
+ pluginEnabled: false,
+};
+
+const agentsConfigSubjects: Dictionary> = {};
+
+const getAgentsConfigSubject = (serverUrl: string) => {
+ if (!agentsConfigSubjects[serverUrl]) {
+ agentsConfigSubjects[serverUrl] = new BehaviorSubject(DefaultAgentsConfig);
+ }
+
+ return agentsConfigSubjects[serverUrl];
+};
+
+export const getAgentsConfig = (serverUrl: string) => {
+ return getAgentsConfigSubject(serverUrl).value;
+};
+
+export const setAgentsConfig = (serverUrl: string, config: Partial) => {
+ const subject = getAgentsConfigSubject(serverUrl);
+ subject.next({...subject.value, ...config});
+};
+
+export const observeAgentsConfig = (serverUrl: string) => {
+ return getAgentsConfigSubject(serverUrl).asObservable();
+};
+
+export const useAgentsConfig = (serverUrl: string) => {
+ const [state, setState] = useState(DefaultAgentsConfig);
+
+ const agentsConfigSubject = getAgentsConfigSubject(serverUrl);
+
+ useEffect(() => {
+ const subscription = agentsConfigSubject.subscribe((agentsConfig) => {
+ setState(agentsConfig);
+ });
+
+ return () => {
+ subscription?.unsubscribe();
+ };
+ }, [agentsConfigSubject]);
+
+ return state;
+};
diff --git a/app/products/agents/types/api.ts b/app/products/agents/types/api.ts
new file mode 100644
index 000000000..379ea2874
--- /dev/null
+++ b/app/products/agents/types/api.ts
@@ -0,0 +1,47 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+/**
+ * Options for channel analysis requests
+ */
+export type ChannelAnalysisOptions = {
+ since?: string;
+ until?: string;
+ days?: number;
+ prompt?: string;
+ unreads_only?: boolean;
+};
+
+/**
+ * Response from channel analysis API
+ */
+export type ChannelAnalysisResponse = {
+ postid: string;
+ channelid: string;
+};
+
+/**
+ * Agent data structure
+ */
+export type Agent = {
+ id: string;
+ displayName: string;
+ username: string;
+ service_type?: string;
+ service_id?: string;
+};
+
+/**
+ * Response from agents list API
+ */
+export type AgentsResponse = {
+ agents: Agent[];
+};
+
+/**
+ * Response from agents status API
+ */
+export type AgentsStatusResponse = {
+ available: boolean;
+ reason?: string;
+};
diff --git a/app/screens/channel/header/header.tsx b/app/screens/channel/header/header.tsx
index b0d182555..338a28f6c 100644
--- a/app/screens/channel/header/header.tsx
+++ b/app/screens/channel/header/header.tsx
@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import {useAgentsConfig} from '@agents/store/agents_config';
import React, {useCallback, useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, Text, View} from 'react-native';
@@ -124,6 +125,7 @@ const ChannelHeader = ({
const serverUrl = useServerUrl();
const callsConfig = getCallsConfig(serverUrl);
+ const {pluginEnabled: agentsEnabled} = useAgentsConfig(serverUrl);
// NOTE: callsEnabledInChannel will be true/false (not undefined) based on explicit state + the DefaultEnabled system setting
// which ultimately comes from channel/index.tsx, and observeIsCallsEnabledInChannel
@@ -193,6 +195,9 @@ const ChannelHeader = ({
if (hasPlaybookRuns && !isDMorGM) {
items += 1;
}
+ if (agentsEnabled) {
+ items += 1; // Ask Agents action (shown in all channel types)
+ }
let height = CHANNEL_ACTIONS_OPTIONS_HEIGHT + SEPARATOR_HEIGHT + MARGIN + (items * ITEM_HEIGHT);
if (Platform.OS === 'android') {
height += BOTTOM_SHEET_ANDROID_OFFSET;
@@ -216,7 +221,7 @@ const ChannelHeader = ({
theme,
closeButtonId: 'close-channel-quick-actions',
});
- }, [isTablet, callsAvailable, isDMorGM, hasPlaybookRuns, theme, onTitlePress, channelId]);
+ }, [isTablet, callsAvailable, isDMorGM, hasPlaybookRuns, agentsEnabled, theme, onTitlePress, channelId]);
const openPlaybooksRuns = useCallback(() => {
// If no active runs, create a new one instead
diff --git a/app/screens/channel/header/quick_actions/index.test.tsx b/app/screens/channel/header/quick_actions/index.test.tsx
index a1557e35d..206ded024 100644
--- a/app/screens/channel/header/quick_actions/index.test.tsx
+++ b/app/screens/channel/header/quick_actions/index.test.tsx
@@ -11,6 +11,10 @@ import ChannelQuickAction from './index';
import type {Database} from '@nozbe/watermelondb';
+jest.mock('@agents/store/agents_config', () => ({
+ useAgentsConfig: jest.fn(() => ({pluginEnabled: true})),
+}));
+
jest.mock('@playbooks/components/channel_actions/playbook_runs_option', () => ({
__esModule: true,
default: jest.fn(),
@@ -62,4 +66,19 @@ describe('ChannelQuickAction', () => {
const {queryByTestId} = renderWithEverything(, {database});
expect(queryByTestId('playbook-runs-option')).toBeNull();
});
+
+ it('shows Ask Agents option in all channel types', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ expect(getByTestId('channel.quick_actions.ask_agents')).toBeTruthy();
+ });
+
+ it('shows Ask Agents option in DM/GM channels', () => {
+ const props = getBaseProps();
+ props.isDMorGM = true;
+ const {getByTestId} = renderWithEverything(, {database});
+
+ expect(getByTestId('channel.quick_actions.ask_agents')).toBeTruthy();
+ });
});
diff --git a/app/screens/channel/header/quick_actions/index.tsx b/app/screens/channel/header/quick_actions/index.tsx
index 7ff7ad01a..5e3a36cf5 100644
--- a/app/screens/channel/header/quick_actions/index.tsx
+++ b/app/screens/channel/header/quick_actions/index.tsx
@@ -1,13 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import {useAgentsConfig} from '@agents/store/agents_config';
import React from 'react';
import {View} from 'react-native';
import ChannelActions from '@components/channel_actions';
+import AskAgentsOption from '@components/channel_actions/ask_agents_option';
import CopyChannelLinkOption from '@components/channel_actions/copy_channel_link_option';
import InfoBox from '@components/channel_actions/info_box';
import LeaveChannelLabel from '@components/channel_actions/leave_channel_label';
+import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import PlaybookRunsOption from '@playbooks/components/channel_actions/playbook_runs_option';
import {dismissBottomSheet} from '@screens/navigation';
@@ -46,6 +49,8 @@ const ChannelQuickAction = ({
isDMorGM,
hasPlaybookRuns,
}: Props) => {
+ const serverUrl = useServerUrl();
+ const {pluginEnabled: agentsEnabled} = useAgentsConfig(serverUrl);
const theme = useTheme();
const styles = getStyleSheet(theme);
@@ -76,6 +81,13 @@ const ChannelQuickAction = ({
showAsLabel={true}
/>
}
+ {agentsEnabled && (
+
+ )}
;
renderContent: () => React.ReactNode;
@@ -869,10 +870,11 @@ type BottomSheetArgs = {
scrollable?: boolean;
}
-export function bottomSheet({title, renderContent, footerComponent, snapPoints, initialSnapIndex = 1, theme, closeButtonId, scrollable = false}: BottomSheetArgs) {
+export function bottomSheet({title, renderContent, footerComponent, snapPoints, initialSnapIndex = 1, theme, closeButtonId, scrollable = false, enableDynamicSizing}: BottomSheetArgs) {
if (isTablet()) {
showModal(Screens.BOTTOM_SHEET, title, {
closeButtonId,
+ enableDynamicSizing,
initialSnapIndex,
renderContent,
footerComponent,
@@ -881,6 +883,7 @@ export function bottomSheet({title, renderContent, footerComponent, snapPoints,
}, bottomSheetModalOptions(theme, closeButtonId));
} else {
showModalOverCurrentContext(Screens.BOTTOM_SHEET, {
+ enableDynamicSizing,
initialSnapIndex,
renderContent,
footerComponent,
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index f09162110..ea84011cd 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -13,6 +13,21 @@
"account.logout_from": "Log out of {serverName}",
"account.settings": "Settings",
"account.your_profile": "Your Profile",
+ "agents.channel_summary.ai_prompt_placeholder": "Ask AI about this channel",
+ "agents.channel_summary.ask_agents": "Ask Agents",
+ "agents.channel_summary.date_picker.submit": "Summarize",
+ "agents.channel_summary.date_picker.title": "Select date range",
+ "agents.channel_summary.error_title": "Unable to start summary",
+ "agents.channel_summary.option.14d": "Summarize last 14 days",
+ "agents.channel_summary.option.7d": "Summarize last 7 days",
+ "agents.channel_summary.option.custom": "Select date range to summarize",
+ "agents.channel_summary.option.unreads": "Summarize unreads",
+ "agents.channel_summary.range_error": "Select a valid date range",
+ "agents.channel_summary.selected_agent": "Selected Agent",
+ "agents.channel_summary.since": "Start",
+ "agents.channel_summary.since_placeholder": "Select date",
+ "agents.channel_summary.until": "End",
+ "agents.channel_summary.until_placeholder": "Select date",
"agents.citations.title": "Sources ({count})",
"agents.controls.regenerate": "Regenerate",
"agents.controls.stop": "Stop Generating",
@@ -22,6 +37,8 @@
"agents.regenerate.confirm": "Regenerate",
"agents.regenerate.confirm_message": "This will clear the current response and generate a new one. Continue?",
"agents.regenerate.confirm_title": "Regenerate Response",
+ "agents.selector.no_agents": "No agents available",
+ "agents.selector.title": "Select Agent",
"agents.tool_call.approve": "Accept",
"agents.tool_call.pending_decisions": "{count, plural, =0 {All tools decided} one {# tool needs a decision} other {# tools need decisions}}",
"agents.tool_call.processing": "Processing...",
diff --git a/detox/e2e/test/products/agents/channel_summary.e2e.ts b/detox/e2e/test/products/agents/channel_summary.e2e.ts
new file mode 100644
index 000000000..92c63ad87
--- /dev/null
+++ b/detox/e2e/test/products/agents/channel_summary.e2e.ts
@@ -0,0 +1,112 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// *******************************************************************
+// - [#] indicates a test step (e.g. # Go to a screen)
+// - [*] indicates an assertion (e.g. * Check the title)
+// - Use element testID when selecting an element. Create one if none.
+// *******************************************************************
+
+import {
+ Setup,
+} from '@support/server_api';
+import {
+ serverOneUrl,
+ siteOneUrl,
+} from '@support/test_config';
+import {
+ ChannelListScreen,
+ ChannelScreen,
+ HomeScreen,
+ LoginScreen,
+ ServerScreen,
+} from '@support/ui/screen';
+import {timeouts, wait} from '@support/utils';
+import {expect} from 'detox';
+
+describe('Agents - Channel Summary', () => {
+ const serverOneDisplayName = 'Server 1';
+ const channelsCategory = 'channels';
+ let testChannel: any;
+
+ beforeAll(async () => {
+ const {channel, user} = await Setup.apiInit(siteOneUrl);
+ testChannel = channel;
+
+ // # Log in to server
+ await ServerScreen.connectToServer(serverOneUrl, serverOneDisplayName);
+ await LoginScreen.login(user);
+ });
+
+ beforeEach(async () => {
+ // * Verify on channel list screen
+ await ChannelListScreen.toBeVisible();
+ });
+
+ afterAll(async () => {
+ // # Log out
+ await HomeScreen.logout();
+ });
+
+ it('should show Ask Agents option in public channel', async () => {
+ // # Open a channel screen
+ await ChannelScreen.open(channelsCategory, testChannel.name);
+
+ // # Open quick actions by tapping the quick actions button
+ await wait(timeouts.ONE_SEC);
+ await ChannelScreen.channelQuickActionsButton.tap();
+
+ // * Verify Ask Agents option is visible
+ await waitFor(element(by.id('channel.quick_actions.ask_agents'))).toBeVisible().withTimeout(timeouts.FOUR_SEC);
+
+ // # Close the bottom sheet by pressing back
+ await device.pressBack();
+ await ChannelScreen.back();
+ });
+
+ it('should open summary sheet and show options', async () => {
+ // # Open a channel screen
+ await ChannelScreen.open(channelsCategory, testChannel.name);
+
+ // # Open quick actions by tapping the quick actions button
+ await wait(timeouts.ONE_SEC);
+ await ChannelScreen.channelQuickActionsButton.tap();
+
+ // # Wait for and tap Ask Agents option to open the summary sheet
+ await waitFor(element(by.id('channel.quick_actions.ask_agents'))).toBeVisible().withTimeout(timeouts.FOUR_SEC);
+ await element(by.id('channel.quick_actions.ask_agents')).tap();
+
+ // * Verify summary options are visible
+ await waitFor(element(by.id('agents.channel_summary.option.unreads'))).toBeVisible().withTimeout(timeouts.FOUR_SEC);
+ await expect(element(by.id('agents.channel_summary.option.7d'))).toBeVisible();
+ await expect(element(by.id('agents.channel_summary.option.custom'))).toBeVisible();
+ await expect(element(by.id('agents.channel_summary.agent_selector'))).toBeVisible();
+
+ // # Open Agent Selector panel
+ await element(by.id('agents.channel_summary.agent_selector')).tap();
+
+ // * Verify Agent Selector back button is visible
+ await waitFor(element(by.id('agents.selector.back'))).toBeVisible().withTimeout(timeouts.FOUR_SEC);
+
+ // # Go back from Agent Selector
+ await element(by.id('agents.selector.back')).tap();
+
+ // # Wait for main options to reappear and open Date Range Picker
+ await waitFor(element(by.id('agents.channel_summary.option.custom'))).toBeVisible().withTimeout(timeouts.FOUR_SEC);
+ await element(by.id('agents.channel_summary.option.custom')).tap();
+
+ // * Verify Date Picker UI elements
+ await waitFor(element(by.id('agents.channel_summary.date_picker.back'))).toBeVisible().withTimeout(timeouts.FOUR_SEC);
+ await expect(element(by.id('agents.channel_summary.date_from'))).toBeVisible();
+
+ // # Go back from Date Picker
+ await element(by.id('agents.channel_summary.date_picker.back')).tap();
+
+ // # Close the bottom sheet by pressing back
+ await wait(timeouts.ONE_SEC);
+ await device.pressBack();
+
+ // # Navigate back to channel list
+ await ChannelScreen.back();
+ });
+});