feat: AI rewrite (#9280)

* feat: ai rewrite

* feat: allow crating content apart from editing it

* feat: feature parity with webapp

* feat: feature parity with webapp

* chore: fixed padding

* map ux to webapp

* refactored ai rewrite logic to separate package

* chore: tests and lint

* Update app/products/ai/rewrite/screens/options/options.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* rewrite post editor animation

* i18n

* ui feedback, centered icon and less top padding

* chore: lint

* Consolidate @ai product into @agents

Move all rewrite functionality from app/products/ai/ into
app/products/agents/ to unify under a single namespace.

- Add rewrite types, store, hooks, components, and screens to agents
- Merge AI client methods into agents client
- Update screen constants (AI_* -> AGENTS_*)
- Update all consumer imports
- Remove AI state from EphemeralStore
- Remove @ai path alias from config
- Delete app/products/ai/ directory

* refactor: load screens from product package

* refactor: move detection logic to the agents pacakge

* refactor: remove "backwards compatibility"

* refactor: move hooks to proper package

* refactor: styles

* refactor: remove unneedd position attribute

* refactor: remove unneeded cancel animation calls

* refactor: "backwards compat"

* refactor: optimize renderContent with useCallback for performance

* refactor: rename variable to avoid confusion

* refactor: update handleRewrite to use async/await for better error handling

* refactor: simplify handleRewrite by always dismissing keyboard

* refactor: use hook, always all keyboard.dismiss

* chore: enhance AgentSelector component with FlatList support

* feat: add rewriteMessage function for AI message rewriting and integrate it into useRewrite hook

* refactor: simplify message length calculation in useHandleSendMessage hook

* refactor: consolidate agent screen constants and integrate with existing screens

* fix: update dependency array in useMemo for isUnrevealedPost to include post expiration metadata

* refactor: remove unused variable to clean up Typing component

* refactor: simplify logic for atDisabled and slashDisabled flags in QuickActions component

* feat: add AIRewriteAction component for AI message rewriting functionality

* revert: thread.ts changes manually

* refactor: integrate useSafeAreaInsets

* refactor: cleanup unused methods

* chore: add comment to clarify casting

* refactor: use_agents

* chore: lint and test

* fix: trim

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Felipe Martin 2026-02-16 16:48:57 +01:00 committed by GitHub
parent 1db5cd456c
commit 714c3dc769
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1411 additions and 125 deletions

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fetchAgents} from '@agents/actions/remote/agents';
import {setLastServerVersionCheck} from '@actions/local/systems';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import DatabaseManager from '@database/manager';
@ -35,6 +37,9 @@ export async function appEntry(serverUrl: string, since = 0) {
verifyPushProxy(serverUrl);
// Fetch agents to determine if AI features are available
fetchAgents(serverUrl);
return {};
}

View file

@ -29,6 +29,12 @@ jest.mock('@screens/navigation', () => ({
jest.mock('@context/server', () => ({
useServerUrl: jest.fn().mockReturnValue('https://server.com'),
withServerUrl: (Component: React.ComponentType<any>) => (props: any) => (
<Component
{...props}
serverUrl={'https://server.com'}
/>
),
}));
jest.mock('@components/draft_scheduled_post_header', () => () => null);

View file

@ -13,6 +13,9 @@ import FormattedDate, {type FormattedDateFormat} from './index';
jest.mock('@utils/log', () => ({
logDebug: jest.fn(),
logError: jest.fn(),
logInfo: jest.fn(),
logWarning: jest.fn(),
}));
const DATE = new Date('2024-10-26T10:01:04.653Z');

View file

@ -24,6 +24,12 @@ const SERVER_URL = 'https://appv1.mattermost.com';
// this is needed to when using the useServerUrl hook
jest.mock('@context/server', () => ({
useServerUrl: jest.fn(() => SERVER_URL),
withServerUrl: (Component: React.ComponentType<any>) => (props: any) => (
<Component
{...props}
serverUrl={SERVER_URL}
/>
),
}));
jest.mock('@screens/navigation', () => ({

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import RewritingIndicator from '@agents/components/rewriting_indicator';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, type LayoutChangeEvent, Platform, ScrollView, View} from 'react-native';
@ -199,6 +200,7 @@ function DraftInput({
return (
<>
<RewritingIndicator/>
<Typing
channelId={channelId}
rootId={rootId}
@ -209,7 +211,6 @@ function DraftInput({
style={style.inputWrapper}
testID={testID}
>
<ScrollView
style={style.inputContainer}
contentContainerStyle={style.inputContentContainer}

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useRewrite} from '@agents/hooks';
import {useHardwareKeyboardEvents} from '@mattermost/hardware-keyboard';
import {useManagedConfig} from '@mattermost/react-native-emm';
import PasteableTextInput, {type PastedFile, type PasteInputRef} from '@mattermost/react-native-paste-input';
@ -10,7 +11,7 @@ import {
Alert, AppState, type AppStateStatus, DeviceEventEmitter, type EmitterSubscription, Keyboard,
type NativeSyntheticEvent, Platform, type TextInputSelectionChangeEventData,
} from 'react-native';
import {runOnUI} from 'react-native-reanimated';
import Animated, {cancelAnimation, Easing, runOnUI, useAnimatedStyle, useSharedValue, withRepeat, withTiming} from 'react-native-reanimated';
import {updateDraftMessage} from '@actions/local/draft';
import {userTyping} from '@actions/websocket/users';
@ -167,6 +168,7 @@ export default function PostInput({
}, [registerPostInputCallbacks]);
const [propagateValue, shouldProcessEvent] = useInputPropagation();
const {isProcessing} = useRewrite();
const lastTypingEventSent = useRef(0);
@ -193,6 +195,28 @@ export default function PostInput({
return {...style.input, maxHeight};
}, [maxHeight, style.input]);
// Pulsing animation for when AI rewrite is processing
const pulseOpacity = useSharedValue(1);
const pulsingAnimatedStyle = useAnimatedStyle(() => ({
opacity: pulseOpacity.value,
}));
useEffect(() => {
if (isProcessing) {
pulseOpacity.value = withRepeat(
withTiming(0.5, {duration: 400, easing: Easing.inOut(Easing.ease)}),
-1,
true,
);
} else {
pulseOpacity.value = withTiming(1, {duration: 200});
}
return () => {
cancelAnimation(pulseOpacity);
};
}, [isProcessing, pulseOpacity]);
const onBlur = useCallback(() => {
handleDraftUpdate({
serverUrl,
@ -516,31 +540,34 @@ export default function PostInput({
useHardwareKeyboardEvents(events);
return (
<PasteableTextInput
allowFontScaling={true}
disableCopyPaste={disableCopyAndPaste}
disableFullscreenUI={true}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
multiline={true}
onBlur={onBlur}
onChangeText={handleTextChange}
onFocus={onFocus}
onPress={Platform.OS === 'android' ? handlePress : undefined}
onPaste={onPaste}
onSelectionChange={handlePostDraftSelectionChanged}
placeholder={intl.formatMessage(getPlaceHolder(rootId), {channelDisplayName})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
ref={inputRef}
showSoftInputOnFocus={Platform.OS === 'android' ? (!showInputAccessoryView || isManuallyFocusingAfterEmojiDismiss) : true}
smartPunctuation='disable'
submitBehavior='newline'
style={pasteInputStyle}
testID={testID}
underlineColorAndroid='transparent'
textContentType='none'
value={value}
autoCapitalize='sentences'
nativeID={testID}
/>
<Animated.View style={pulsingAnimatedStyle}>
<PasteableTextInput
allowFontScaling={true}
disableCopyPaste={disableCopyAndPaste}
disableFullscreenUI={true}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
multiline={true}
onBlur={onBlur}
onChangeText={handleTextChange}
onFocus={onFocus}
onPress={Platform.OS === 'android' ? handlePress : undefined}
onPaste={onPaste}
onSelectionChange={handlePostDraftSelectionChanged}
placeholder={intl.formatMessage(getPlaceHolder(rootId), {channelDisplayName})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
ref={inputRef}
showSoftInputOnFocus={Platform.OS === 'android' ? (!showInputAccessoryView || isManuallyFocusingAfterEmojiDismiss) : true}
smartPunctuation='disable'
submitBehavior='newline'
style={pasteInputStyle}
testID={testID}
underlineColorAndroid='transparent'
textContentType='none'
value={value}
autoCapitalize='sentences'
editable={!isProcessing}
nativeID={testID}
/>
</Animated.View>
);
}

View file

@ -1,9 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {observeIsAgentsEnabled} from '@agents/queries/agents';
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import React from 'react';
import {withServerUrl} from '@context/server';
import {observeIsBoREnabled, observeIsPostPriorityEnabled} from '@queries/servers/post';
import {observeCanUploadFiles} from '@queries/servers/security';
import {observeMaxFileCount} from '@queries/servers/system';
@ -12,16 +14,21 @@ import QuickActions from './quick_actions';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
type EnhancedProps = WithDatabaseArgs & {
serverUrl: string;
}
const enhanced = withObservables([], ({database, serverUrl}: EnhancedProps) => {
const canUploadFiles = observeCanUploadFiles(database);
const maxFileCount = observeMaxFileCount(database);
return {
canUploadFiles,
isAgentsEnabled: observeIsAgentsEnabled(serverUrl),
isPostPriorityEnabled: observeIsPostPriorityEnabled(database),
isBoREnabled: observeIsBoREnabled(database),
maxFileCount,
};
});
export default React.memo(withDatabase(enhanced(QuickActions)));
export default React.memo(withDatabase(withServerUrl(enhanced(QuickActions))));

View file

@ -19,6 +19,7 @@ describe('Quick Actions', () => {
testID: 'test-quick-actions',
canUploadFiles: true,
fileCount: 0,
isAgentsEnabled: true,
isPostPriorityEnabled: true,
canShowPostPriority: true,
canShowSlashCommands: true,

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import AIRewriteAction from '@agents/components/ai_rewrite_action';
import React from 'react';
import {StyleSheet, View} from 'react-native';
@ -18,6 +19,7 @@ type Props = {
testID?: string;
canUploadFiles: boolean;
fileCount: number;
isAgentsEnabled: boolean;
isPostPriorityEnabled: boolean;
isBoREnabled: boolean;
canShowPostPriority?: boolean;
@ -53,6 +55,7 @@ export default function QuickActions({
canUploadFiles,
value,
fileCount,
isAgentsEnabled,
isPostPriorityEnabled,
isBoREnabled,
canShowSlashCommands = true,
@ -68,7 +71,7 @@ export default function QuickActions({
postBoRConfig,
location,
}: Props) {
const atDisabled = value[value.length - 1] === '@';
const atDisabled = value.endsWith('@');
const slashDisabled = value.length > 0;
const showBoRAction = isBoREnabled && updatePostBoRStatus && location === Screens.CHANNEL;
@ -76,6 +79,7 @@ export default function QuickActions({
const slashInputActionTestID = `${testID}.slash_input_action`;
const emojiActionTestID = `${testID}.emoji_action`;
const attachmentActionTestID = `${testID}.attachment_action`;
const aiRewriteActionTestID = `${testID}.ai_rewrite_action`;
const postPriorityActionTestID = `${testID}.post_priority_action`;
const borPriorityActionTestID = `${testID}.bor_action`;
@ -117,6 +121,13 @@ export default function QuickActions({
testID={emojiActionTestID}
/>
)}
{isAgentsEnabled && (
<AIRewriteAction
testID={aiRewriteActionTestID}
value={value}
updateValue={updateValue}
/>
)}
{isPostPriorityEnabled && canShowPostPriority && (
<PostPriorityAction
testID={postPriorityActionTestID}

View file

@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import {type StyleProp, type ViewStyle} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {TYPING_HEIGHT} from '@constants/post_draft';
type Props = {
visible: boolean;
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
}
export const STATUS_INDICATOR_HEIGHT = TYPING_HEIGHT;
function StatusIndicator({
visible,
children,
style,
}: Props) {
const height = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => {
return {
height: withTiming(height.value),
marginBottom: 4,
overflow: 'hidden',
};
});
useEffect(() => {
height.value = visible ? STATUS_INDICATOR_HEIGHT : 0;
}, [visible, height]);
return (
<Animated.View style={[animatedStyle, style]}>
{children}
</Animated.View>
);
}
export default React.memo(StatusIndicator);

View file

@ -3,11 +3,10 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {DeviceEventEmitter} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import FormattedText from '@components/formatted_text';
import StatusIndicator from '@components/post_draft/status_indicator';
import {Events} from '@constants';
import {TYPING_HEIGHT} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -31,23 +30,15 @@ function Typing({
channelId,
rootId,
}: Props) {
const typingHeight = useSharedValue(0);
const typing = useRef<Array<{id: string; now: number; username: string}>>([]);
const timeoutToDisappear = useRef<NodeJS.Timeout>();
const mounted = useRef(false);
const [refresh, setRefresh] = useState(0);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [refresh, setRefresh] = useState(0); // Used to trigger re-renders when typing state changes
const theme = useTheme();
const style = getStyleSheet(theme);
// This moves the list of post up. This may be rethought by UX in https://mattermost.atlassian.net/browse/MM-39681
const typingAnimatedStyle = useAnimatedStyle(() => {
return {
height: withTiming(typingHeight.value),
marginBottom: 4,
};
});
const onUserStartTyping = useCallback((msg: any) => {
if (channelId !== msg.channelId) {
return;
@ -119,18 +110,13 @@ function Typing({
};
}, [onUserStopTyping]);
useEffect(() => {
typingHeight.value = typing.current.length ? TYPING_HEIGHT : 0;
}, [refresh, typingHeight]);
useEffect(() => {
typing.current = [];
typingHeight.value = 0;
if (timeoutToDisappear.current) {
clearTimeout(timeoutToDisappear.current);
timeoutToDisappear.current = undefined;
}
}, [channelId, rootId, typingHeight]);
}, [channelId, rootId]);
const renderTyping = () => {
const nextTyping = typing.current.map(({username}) => username);
@ -175,12 +161,13 @@ function Typing({
}
};
const isVisible = typing.current.length > 0;
return (
<Animated.View style={typingAnimatedStyle}>
<StatusIndicator visible={isVisible}>
{renderTyping()}
</Animated.View>
</StatusIndicator>
);
}
export default React.memo(Typing);

View file

@ -20,7 +20,12 @@ import {getPostTranslation, postUserDisplayName} from '@utils/post';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {ensureString} from '@utils/types';
import {typography} from '@utils/typography';
import {displayUsername, getUserCustomStatus, getUserTimezone, isCustomStatusExpired} from '@utils/user';
import {
displayUsername,
getUserCustomStatus,
getUserTimezone,
isCustomStatusExpired,
} from '@utils/user';
import HeaderCommentedOn from './commented_on';
import HeaderDisplayName from './display_name';
@ -33,27 +38,27 @@ import type UserModel from '@typings/database/models/servers/user';
import type {AvailableScreens} from '@typings/screens/navigation';
type HeaderProps = {
author?: UserModel;
commentCount: number;
currentUser?: UserModel;
enablePostUsernameOverride: boolean;
isAutoResponse: boolean;
isCRTEnabled?: boolean;
isCustomStatusEnabled: boolean;
isEphemeral: boolean;
isMilitaryTime: boolean;
isPendingOrFailed: boolean;
isSystemPost: boolean;
isWebHook: boolean;
location: AvailableScreens;
post: PostModel;
rootPostAuthor?: UserModel;
showPostPriority: boolean;
shouldRenderReplyButton?: boolean;
teammateNameDisplay: string;
hideGuestTags: boolean;
isChannelAutotranslated: boolean;
}
author?: UserModel;
commentCount: number;
currentUser?: UserModel;
enablePostUsernameOverride: boolean;
isAutoResponse: boolean;
isCRTEnabled?: boolean;
isCustomStatusEnabled: boolean;
isEphemeral: boolean;
isMilitaryTime: boolean;
isPendingOrFailed: boolean;
isSystemPost: boolean;
isWebHook: boolean;
location: AvailableScreens;
post: PostModel;
rootPostAuthor?: UserModel;
showPostPriority: boolean;
shouldRenderReplyButton?: boolean;
teammateNameDisplay: string;
hideGuestTags: boolean;
isChannelAutotranslated: boolean;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
@ -109,19 +114,46 @@ const Header = ({
const style = getStyleSheet(theme);
const pendingPostStyle = isPendingOrFailed ? style.pendingPost : undefined;
const isReplyPost = Boolean(post.rootId && !isEphemeral);
const showReply = !isReplyPost && (location !== THREAD) && (shouldRenderReplyButton && (!rootPostAuthor && commentCount > 0));
const displayName = postUserDisplayName(post, author, teammateNameDisplay, enablePostUsernameOverride);
const rootAuthorDisplayName = rootPostAuthor ? displayUsername(rootPostAuthor, currentUser?.locale, teammateNameDisplay, true) : undefined;
const showReply =
!isReplyPost &&
location !== THREAD &&
shouldRenderReplyButton &&
!rootPostAuthor &&
commentCount > 0;
const displayName = postUserDisplayName(
post,
author,
teammateNameDisplay,
enablePostUsernameOverride,
);
const rootAuthorDisplayName = rootPostAuthor
? displayUsername(
rootPostAuthor,
currentUser?.locale,
teammateNameDisplay,
true,
)
: undefined;
const customStatus = getUserCustomStatus(author);
const showCustomStatusEmoji = Boolean(
isCustomStatusEnabled && displayName && customStatus &&
const showCustomStatusEmoji =
Boolean(
isCustomStatusEnabled &&
displayName &&
customStatus &&
!(isSystemPost || author?.isBot || isAutoResponse || isWebHook),
) && !isCustomStatusExpired(author) && Boolean(customStatus?.emoji);
) &&
!isCustomStatusExpired(author) &&
Boolean(customStatus?.emoji);
const userIconOverride = ensureString(post.props?.override_icon_url);
const usernameOverride = ensureString(post.props?.override_username);
const intl = useIntl();
const showBoRIcon = useMemo(() => isUnrevealedBoRPost(post), [post, post.metadata?.expire_at]);
/* eslint-disable react-hooks/exhaustive-deps -- expire_at triggers recomputation when post metadata changes */
const showBoRIcon = useMemo(
() => isUnrevealedBoRPost(post),
[post, post.metadata?.expire_at],
);
/* eslint-enable react-hooks/exhaustive-deps */
const borExpireAt = post.metadata?.expire_at;
const serverUrl = useServerUrl();
@ -149,13 +181,13 @@ const Header = ({
showCustomStatusEmoji={showCustomStatusEmoji}
customStatus={customStatus!}
/>
{(!isSystemPost || isAutoResponse) &&
<HeaderTag
isAutoResponder={isAutoResponse}
isAutomation={isWebHook || author?.isBot}
showGuestTag={author?.isGuest && !hideGuestTags}
/>
}
{(!isSystemPost || isAutoResponse) && (
<HeaderTag
isAutoResponder={isAutoResponse}
isAutomation={isWebHook || author?.isBot}
showGuestTag={author?.isGuest && !hideGuestTags}
/>
)}
<FormattedTime
timezone={getUserTimezone(currentUser)}
isMilitaryTime={isMilitaryTime}
@ -163,9 +195,9 @@ const Header = ({
style={style.time}
testID='post_header.date_time'
/>
{isChannelAutotranslated && post.type === '' &&
{isChannelAutotranslated && post.type === '' && (
<TranslateIcon translationState={translation?.state}/>
}
)}
{isEphemeral && (
<FormattedText
id='post_header.visible_message'
@ -175,41 +207,38 @@ const Header = ({
/>
)}
{showPostPriority && post.metadata?.priority?.priority && (
<PostPriorityLabel
label={post.metadata.priority.priority}
/>
<PostPriorityLabel label={post.metadata.priority.priority}/>
)}
{showBoRIcon &&
{showBoRIcon && (
<CompassIcon
name='fire'
size={16}
color={theme.dndIndicator}
/>
}
{
Boolean(borExpireAt) &&
)}
{Boolean(borExpireAt) && (
<ExpiryTimer
expiryTime={borExpireAt as number}
onExpiry={onBoRPostExpiry}
/>
}
{!isCRTEnabled && showReply && commentCount > 0 &&
)}
{!isCRTEnabled && showReply && commentCount > 0 && (
<HeaderReply
commentCount={commentCount}
location={location}
post={post}
theme={theme}
/>
}
)}
</View>
</View>
{Boolean(rootAuthorDisplayName) && location === CHANNEL &&
<HeaderCommentedOn
locale={currentUser?.locale || DEFAULT_LOCALE}
name={rootAuthorDisplayName!}
theme={theme}
/>
}
{Boolean(rootAuthorDisplayName) && location === CHANNEL && (
<HeaderCommentedOn
locale={currentUser?.locale || DEFAULT_LOCALE}
name={rootAuthorDisplayName!}
theme={theme}
/>
)}
</>
);
};

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import AGENTS_SCREENS from '@agents/constants/screens';
import PLAYBOOKS_SCREENS from '@playbooks/constants/screens';
export const ABOUT = 'About';
@ -185,6 +187,7 @@ export default {
USER_PROFILE,
SHOW_TRANSLATION,
...PLAYBOOKS_SCREENS,
...AGENTS_SCREENS,
} as const;
export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
@ -214,6 +217,8 @@ export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set<string>([
]);
export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
AGENTS_SCREENS.AGENTS_SELECTOR,
AGENTS_SCREENS.AGENTS_REWRITE_OPTIONS,
ATTACHMENT_OPTIONS,
BOTTOM_SHEET,
DRAFT_SCHEDULED_POST_OPTIONS,

View file

@ -107,6 +107,9 @@ export function useGalleryItem(
useEffect(() => {
gallery.registerItem(index, ref);
// Only register item once on mount - gallery, index, and ref are stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onGestureEvent = () => {

View file

@ -187,7 +187,7 @@ export const useHandleSendMessage = ({
}, [intl, channelTimezoneCount, doSubmitMessage]);
const sendCommand = useCallback(async () => {
if (value.trim().startsWith('/call')) {
if (value && value.trim().startsWith('/call')) {
const {handled, error} = await handleCallsSlashCommand(value.trim(), serverUrl, channelId, channelType ?? '', rootId, currentUserId, intl);
if (handled) {
setSendingMessage(false);
@ -227,15 +227,15 @@ export const useHandleSendMessage = ({
clearDraft();
if (data?.goto_location && !value.startsWith('/leave')) {
if (data?.goto_location && value && !value.startsWith('/leave')) {
handleGotoLocation(serverUrl, intl, data.goto_location);
}
}, [value, userIsOutOfOffice, serverUrl, intl, channelId, rootId, clearDraft, channelType, currentUserId]);
const sendMessage = useCallback(async (schedulingInfo?: SchedulingInfo) => {
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;
const toAllOrChannel = DraftUtils.textContainsAtAllAtChannel(value);
const toHere = DraftUtils.textContainsAtHere(value);
const toAllOrChannel = value ? DraftUtils.textContainsAtAllAtChannel(value) : false;
const toHere = value ? DraftUtils.textContainsAtHere(value) : false;
if (value.indexOf('/') === 0 && !schedulingInfo) {
// Don't execute slash command when scheduling message

View file

@ -12,6 +12,9 @@ import type ServerDataOperator from '@database/operator/server_data_operator';
jest.mock('@utils/log', () => ({
logDebug: () => '',
logError: jest.fn(),
logInfo: jest.fn(),
logWarning: jest.fn(),
}));
const {

View file

@ -1,21 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {type Agent} from '@agents/client/rest';
import {rewriteStore} from '@agents/store';
import NetworkManager from '@managers/network_manager';
import {getFullErrorMessage} from '@utils/errors';
import {logError} from '@utils/log';
import {logDebug, logError} from '@utils/log';
export async function fetchAgents(
serverUrl: string,
): Promise<{data?: Agent[]; error?: string}> {
import type {RewriteAction} from '@agents/types';
/**
* Fetch available agents from the server and store them in the rewrite store
*/
export const fetchAgents = async (serverUrl: string) => {
try {
const client = NetworkManager.getClient(serverUrl);
const agents = await client.getAgents();
// Store agents in rewriteStore
rewriteStore.setAgents(serverUrl, agents);
return {data: agents};
} catch (error) {
logError('[fetchAgents]', error);
logDebug('[fetchAgents] Error:', getFullErrorMessage(error));
return {error};
}
};
/**
* Rewrite a message using the AI service
* @param serverUrl The server URL
* @param message The message to rewrite
* @param action The rewrite action to perform
* @param customPrompt Optional custom prompt for the rewrite
* @param agentId Optional agent ID to use for the rewrite
* @returns {rewrittenText} on success, {error} on failure
*/
export async function rewriteMessage(
serverUrl: string,
message: string,
action: RewriteAction,
customPrompt: string | undefined,
agentId: string | undefined,
): Promise<{rewrittenText?: string; error?: unknown}> {
try {
const client = NetworkManager.getClient(serverUrl);
const rewrittenText = await client.getRewrittenMessage(message, action, customPrompt, agentId);
return {rewrittenText};
} catch (error) {
logError('[rewriteMessage]', error);
return {error: getFullErrorMessage(error)};
}
}

View file

@ -1,7 +1,7 @@
// 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';
import type {Agent, AgentsResponse, AgentsStatusResponse, ChannelAnalysisOptions, ChannelAnalysisResponse, RewriteRequest, RewriteResponse} from '@agents/types/api';
export type {Agent};
@ -17,6 +17,9 @@ export interface ClientAgentsMix {
options?: ChannelAnalysisOptions,
) => Promise<ChannelAnalysisResponse>;
submitToolApproval: (postId: string, acceptedToolIds: string[]) => Promise<void>;
// Rewrite methods
getRewrittenMessage: (message: string, action?: string, customPrompt?: string, agentId?: string) => Promise<string>;
getAgentsStatus: () => Promise<AgentsStatusResponse>;
}
@ -85,6 +88,33 @@ const ClientAgents = (superclass: any) => class extends superclass {
);
};
// =========================================================================
// Rewrite Methods
// =========================================================================
getRewrittenMessage = async (message: string, action?: string, customPrompt?: string, agentId?: string): Promise<string> => {
const body: RewriteRequest = {
agent_id: agentId,
message,
action,
custom_prompt: customPrompt,
};
const response = await this.doFetch(
`${this.urlVersion}/posts/rewrite`,
{method: 'post', body},
true,
) as RewriteResponse;
// Handle cases where the AI returns plain text instead of the expected JSON format.
// If rewritten_text is undefined, treat the entire response as the rewritten message.
if (response.rewritten_text === undefined) {
return response as unknown as string;
}
return response.rewritten_text;
};
getAgentsStatus = async (): Promise<AgentsStatusResponse> => {
return this.doFetch(
`${this.urlVersion}/agents/status`,

View file

@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useRewrite} from '@agents/hooks';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {openAsBottomSheet} from '@screens/navigation';
import {changeOpacity} from '@utils/theme';
const ICON_SIZE = 24;
type Props = {
testID?: string;
disabled?: boolean;
value: string;
updateValue: (value: string | ((prevValue: string) => string)) => void;
}
const styles = {
icon: {
alignItems: 'center' as const,
justifyContent: 'center' as const,
padding: 10,
},
};
export default function AIRewriteAction({
testID,
disabled = false,
value,
updateValue,
}: Props) {
const intl = useIntl();
const theme = useTheme();
const isTablet = useIsTablet();
const {isProcessing} = useRewrite();
const handlePress = useCallback(() => {
Keyboard.dismiss();
const title = isTablet ? intl.formatMessage({id: 'ai_rewrite.title', defaultMessage: 'AI Rewrite'}) : '';
openAsBottomSheet({
closeButtonId: 'close-ai-rewrite',
screen: Screens.AGENTS_REWRITE_OPTIONS,
theme,
title,
props: {
closeButtonId: 'close-ai-rewrite',
originalMessage: value,
updateValue,
},
});
}, [intl, isTablet, theme, value, updateValue]);
const isDisabled = disabled || isProcessing;
const actionTestID = isDisabled ? `${testID}.disabled` : testID;
const iconColor = isDisabled ?
changeOpacity(theme.centerChannelColor, 0.16) :
changeOpacity(theme.centerChannelColor, 0.64);
return (
<TouchableWithFeedback
testID={actionTestID}
disabled={isDisabled}
onPress={handlePress}
style={styles.icon}
type={'opacity'}
>
<CompassIcon
name='creation-outline'
color={iconColor}
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}

View file

@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useRewrite} from '@agents/hooks';
import React, {useEffect} from 'react';
import {useIntl} from 'react-intl';
import {StyleSheet, Text, View} from 'react-native';
import Animated, {cancelAnimation, Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming} from 'react-native-reanimated';
import StatusIndicator from '@components/post_draft/status_indicator';
import {useTheme} from '@context/theme';
import {changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
const SPINNER_SIZE = 10;
const getStyleSheet = (theme: Theme) => StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 10,
},
spinner: {
height: SPINNER_SIZE,
width: SPINNER_SIZE,
borderRadius: SPINNER_SIZE / 2,
borderWidth: 1.5,
borderLeftColor: changeOpacity(theme.centerChannelColor, 0.7),
borderTopColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRightColor: changeOpacity(theme.centerChannelColor, 0.2),
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2),
marginRight: 6,
},
text: {
color: changeOpacity(theme.centerChannelColor, 0.7),
...typography('Body', 75),
},
});
function RewritingIndicator() {
const {isProcessing} = useRewrite();
const theme = useTheme();
const intl = useIntl();
const styles = getStyleSheet(theme);
const rotation = useSharedValue(0);
const spinnerAnimatedStyle = useAnimatedStyle(() => ({
transform: [{rotateZ: `${rotation.value}deg`}],
}));
useEffect(() => {
if (isProcessing) {
rotation.value = withRepeat(
withTiming(360, {duration: 750, easing: Easing.linear}),
-1,
);
}
return () => {
cancelAnimation(rotation);
};
}, [isProcessing, rotation]);
return (
<StatusIndicator visible={isProcessing}>
<View style={styles.container}>
<Animated.View style={[styles.spinner, spinnerAnimatedStyle]}/>
<Text style={styles.text}>
{intl.formatMessage({
id: 'ai_rewrite.rewriting',
defaultMessage: 'Rewriting...',
})}
</Text>
</View>
</StatusIndicator>
);
}
export default React.memo(RewritingIndicator);

View file

@ -0,0 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const AGENTS_SELECTOR = 'AgentsSelector';
export const AGENTS_REWRITE_OPTIONS = 'AgentsRewriteOptions';
export default {
AGENTS_SELECTOR,
AGENTS_REWRITE_OPTIONS,
} as const;

View file

@ -0,0 +1,5 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {useRewrite, useRewriteState} from './use_rewrite';
export {useAgents} from './use_agents';

View file

@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {rewriteStore} from '@agents/store';
import {useEffect, useState} from 'react';
import type {Agent} from '@agents/types';
/**
* React hook to subscribe to agents for a server
*/
export function useAgents(serverUrl: string): Agent[] {
const [agents, setAgents] = useState<Agent[]>(
() => rewriteStore.getAgents(serverUrl),
);
useEffect(() => {
const subscription = rewriteStore.observeAgents(serverUrl).subscribe(setAgents);
return () => subscription.unsubscribe();
}, [serverUrl]);
return agents;
}

View file

@ -0,0 +1,185 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {rewriteMessage} from '@agents/actions/remote/agents';
import {rewriteStore, type RewriteState} from '@agents/store';
import {useCallback, useEffect, useRef, useState} from 'react';
import {logWarning} from '@utils/log';
import type {RewriteAction} from '@agents/types';
const TIMEOUT_MS = 30000; // 30 seconds
type StartRewriteCallback = (
serverUrl: string,
message: string,
action: RewriteAction,
customPrompt: string | undefined,
agentId: string | undefined,
onSuccess: (rewrittenText: string) => void,
onError: (error: string) => void,
) => void;
type UseRewriteReturn = {
isProcessing: boolean;
startRewrite: StartRewriteCallback;
cancelRewrite: () => void;
};
/**
* Hook that manages AI rewrite state across screens
*/
export const useRewrite = (): UseRewriteReturn => {
const [isProcessing, setIsProcessing] = useState(rewriteStore.isRewriteProcessing());
const currentPromiseRef = useRef<Promise<{rewrittenText?: string; error?: unknown}> | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const isCancelledRef = useRef(false);
// Subscribe to rewrite store changes
useEffect(() => {
const subscription = rewriteStore.observeRewriteState().subscribe((state) => {
setIsProcessing(state.isProcessing);
});
return () => subscription.unsubscribe();
}, []);
const cancelRewrite = useCallback(() => {
isCancelledRef.current = true;
currentPromiseRef.current = null;
rewriteStore.setRewriteProcessing(false, '');
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
const startRewrite = useCallback((
serverUrl: string,
message: string,
action: RewriteAction,
customPrompt: string | undefined,
agentId: string | undefined,
onSuccess: (rewrittenText: string) => void,
onError: (error: string) => void,
) => {
if (rewriteStore.isRewriteProcessing()) {
logWarning('[useRewrite] Rewrite already in progress');
return;
}
rewriteStore.setRewriteProcessing(true, serverUrl);
isCancelledRef.current = false;
// Create timeout promise
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutRef.current = setTimeout(() => {
reject(new Error('timeout'));
}, TIMEOUT_MS);
});
const runRewrite = async () => {
try {
const rewritePromise = rewriteMessage(serverUrl, message, action, customPrompt, agentId);
currentPromiseRef.current = rewritePromise;
const result = await Promise.race([rewritePromise, timeoutPromise]);
// Clear timeout if successful
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// Check if this is still the current promise (not cancelled)
if (currentPromiseRef.current === rewritePromise && !isCancelledRef.current) {
// Handle error from remote action
if (result.error) {
logWarning('[useRewrite] Error from remote action:', result.error);
rewriteStore.setRewriteProcessing(false, '');
currentPromiseRef.current = null;
onError('An error occurred while rewriting your message. Please try again.');
return;
}
// Ensure response is a valid non-empty string
const response = result.rewrittenText;
if (response && typeof response === 'string' && response.trim().length > 0) {
// If response is a JSON-encoded string, parse it to get actual newlines/escapes
let formattedResponse = response;
if (response.startsWith('"') && response.endsWith('"')) {
try {
formattedResponse = JSON.parse(response);
} catch (e) {
logWarning('[useRewrite] Failed to parse JSON-encoded response, using raw:', e);
}
}
rewriteStore.setRewriteProcessing(false, '');
currentPromiseRef.current = null;
onSuccess(formattedResponse);
} else {
logWarning('[useRewrite] Invalid or empty response received:', response);
rewriteStore.setRewriteProcessing(false, '');
currentPromiseRef.current = null;
onError('Received an invalid response from AI. Please try again.');
}
} else if (isCancelledRef.current) {
// Operation was cancelled, just cleanup
rewriteStore.setRewriteProcessing(false, '');
currentPromiseRef.current = null;
}
} catch (error) {
// Clear timeout on error
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// Only handle error if this is still the current promise and not cancelled
if (currentPromiseRef.current && !isCancelledRef.current) {
logWarning('[useRewrite] Error:', error);
let errorMsg = 'An error occurred while rewriting your message. Please try again.';
if (error instanceof Error && error.message === 'timeout') {
errorMsg = 'The AI request timed out. Please try again.';
}
rewriteStore.setRewriteProcessing(false, '');
currentPromiseRef.current = null;
onError(errorMsg);
} else if (isCancelledRef.current) {
// Operation was cancelled, just cleanup
rewriteStore.setRewriteProcessing(false, '');
currentPromiseRef.current = null;
}
}
};
runRewrite();
}, []);
return {
isProcessing,
startRewrite,
cancelRewrite,
};
};
/**
* React hook to subscribe to rewrite state
*/
export function useRewriteState(): RewriteState {
const [state, setState] = useState<RewriteState>(
() => rewriteStore.getRewriteState(),
);
useEffect(() => {
const subscription = rewriteStore.observeRewriteState().subscribe(setState);
return () => subscription.unsubscribe();
}, []);
return state;
}

View file

@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {rewriteStore} from '@agents/store';
import {map} from 'rxjs/operators';
export const observeIsAgentsEnabled = (serverUrl: string) => {
return rewriteStore.observeAgents(serverUrl).pipe(
map((agents) => agents.length > 0),
);
};

View file

@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import OptionItem from '@components/option_item';
import type {Agent} from '@agents/types';
type Props = {
agent: Agent;
selectedAgentId: string;
onSelect: (agent: Agent) => void;
};
const AgentItem = ({agent, selectedAgentId, onSelect}: Props) => {
const handleSelect = useCallback(() => {
onSelect(agent);
}, [agent, onSelect]);
return (
<OptionItem
label={agent.displayName}
description={`@${agent.username}${agent.service_type}`}
action={handleSelect}
type='radio'
selected={agent.id === selectedAgentId}
testID={`ai_agent_selector.agent.${agent.id}`}
descriptionNumberOfLines={1}
/>
);
};
export default AgentItem;

View file

@ -0,0 +1,127 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BottomSheetFlatList} from '@gorhom/bottom-sheet';
import React, {useCallback, useMemo} from 'react';
import {type ListRenderItemInfo, View} from 'react-native';
import {FlatList} from 'react-native-gesture-handler';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {ITEM_HEIGHT} from '@components/option_item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useBottomSheetListsFix} from '@hooks/bottom_sheet_lists_fix';
import {useIsTablet} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import BottomSheet from '@screens/bottom_sheet';
import {dismissModal} from '@screens/navigation';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {makeStyleSheetFromTheme} from '@utils/theme';
import AgentItem from './agent_item';
import type {Agent} from '@agents/types';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
componentId: AvailableScreens;
closeButtonId: string;
agents: Agent[];
selectedAgentId: string;
onSelectAgent: (agent: Agent) => void;
};
const OPTIONS_PADDING = 12;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flexGrow: 1,
backgroundColor: theme.centerChannelBg,
},
contentContainer: {
paddingTop: OPTIONS_PADDING,
},
}));
const keyExtractor = (item: Agent) => item.id;
const AgentSelector = ({
componentId,
closeButtonId,
agents,
selectedAgentId,
onSelectAgent,
}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const isTablet = useIsTablet();
const insets = useSafeAreaInsets();
const {enabled, panResponder} = useBottomSheetListsFix();
const close = useCallback(() => {
dismissModal({componentId});
}, [componentId]);
useNavButtonPressed(closeButtonId, componentId, close, []);
useAndroidHardwareBackHandler(componentId, close);
const handleSelectAgent = useCallback((agent: Agent) => {
onSelectAgent(agent);
close();
}, [onSelectAgent, close]);
const List = useMemo(() => (isTablet ? FlatList : BottomSheetFlatList), [isTablet]);
const renderItem = useCallback(({item}: ListRenderItemInfo<Agent>) => (
<AgentItem
agent={item}
selectedAgentId={selectedAgentId}
onSelect={handleSelectAgent}
/>
), [selectedAgentId, handleSelectAgent]);
const snapPoints = useMemo(() => {
const paddingBottom = 10;
// Calculate height based on number of agents
const optionsHeight = OPTIONS_PADDING + bottomSheetSnapPoint(agents.length, ITEM_HEIGHT);
const componentHeight = optionsHeight + paddingBottom + insets.bottom;
const points: Array<string | number> = [1, componentHeight];
// Add scrollable snap point if there are many agents
if (agents.length > 5) {
points.push('80%');
}
return points;
}, [agents.length, insets.bottom]);
const renderContent = () => (
<View style={styles.container}>
<List
data={agents}
renderItem={renderItem}
keyExtractor={keyExtractor}
contentContainerStyle={styles.contentContainer}
testID='ai_agent_selector.flat_list'
scrollEnabled={enabled}
{...panResponder.panHandlers}
/>
</View>
);
return (
<BottomSheet
renderContent={renderContent}
closeButtonId={closeButtonId}
componentId={Screens.AGENTS_SELECTOR}
initialSnapIndex={1}
snapPoints={snapPoints}
testID='ai_agent_selector'
/>
);
};
export default AgentSelector;

View file

@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Screens from '@agents/constants/screens';
import {withServerDatabase} from '@database/components';
export function loadAgentsScreen(screenName: string | number) {
switch (screenName) {
case Screens.AGENTS_SELECTOR:
return withServerDatabase(require('@agents/screens/agent_selector').default);
case Screens.AGENTS_REWRITE_OPTIONS:
return withServerDatabase(require('@agents/screens/rewrite_options').default);
default:
return undefined;
}
}

View file

@ -0,0 +1,356 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useAgents, useRewrite} from '@agents/hooks';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {Alert, Keyboard, TextInput, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import OptionItem, {ITEM_HEIGHT} from '@components/option_item';
import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import BottomSheet from '@screens/bottom_sheet';
import {dismissBottomSheet, openAsBottomSheet} from '@screens/navigation';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {logWarning} from '@utils/log';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type {Agent, RewriteAction} from '@agents/types';
import type {AvailableScreens} from '@typings/screens/navigation';
const messages = defineMessages({
errorTitle: {
id: 'ai_rewrite.error.title',
defaultMessage: 'AI Rewrite Error',
},
errorOk: {
id: 'ai_rewrite.error.ok',
defaultMessage: 'OK',
},
agentSelectorTitle: {
id: 'ai_rewrite.agent_selector_title',
defaultMessage: 'Select AI Agent',
},
selectedAgent: {
id: 'ai_rewrite.selected_agent',
defaultMessage: 'Selected Agent',
},
noAgentSelected: {
id: 'ai_rewrite.no_agent_selected',
defaultMessage: 'None',
},
generatePrompt: {
id: 'ai_rewrite.generate_prompt',
defaultMessage: 'Ask agents to create a message',
},
customPrompt: {
id: 'ai_rewrite.custom_prompt',
defaultMessage: 'Ask AI to edit message...',
},
shorten: {
id: 'ai_rewrite.shorten',
defaultMessage: 'Shorten',
},
elaborate: {
id: 'ai_rewrite.elaborate',
defaultMessage: 'Elaborate',
},
improveWriting: {
id: 'ai_rewrite.improve_writing',
defaultMessage: 'Improve writing',
},
fixSpelling: {
id: 'ai_rewrite.fix_spelling',
defaultMessage: 'Fix spelling and grammar',
},
simplify: {
id: 'ai_rewrite.simplify',
defaultMessage: 'Simplify',
},
summarize: {
id: 'ai_rewrite.summarize',
defaultMessage: 'Summarize',
},
});
type Props = {
closeButtonId: string;
componentId: AvailableScreens;
originalMessage: string;
updateValue: (value: string | ((prevValue: string) => string)) => void;
};
const CUSTOM_PROMPT_INPUT_HEIGHT = 64;
const OPTIONS_PADDING = 8;
const options: Array<{action: RewriteAction; message: typeof messages.shorten; icon: string}> = [
{action: 'shorten', message: messages.shorten, icon: 'text-short'},
{action: 'elaborate', message: messages.elaborate, icon: 'text-long'},
{action: 'improve_writing', message: messages.improveWriting, icon: 'auto-fix'},
{action: 'fix_spelling', message: messages.fixSpelling, icon: 'spellcheck'},
{action: 'simplify', message: messages.simplify, icon: 'creation-outline'},
{action: 'summarize', message: messages.summarize, icon: 'ai-summarize'},
];
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
backgroundColor: theme.centerChannelBg,
},
headerContainer: {
gap: 4,
},
customPromptContainer: {},
customPromptInputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
borderRadius: 4,
paddingHorizontal: 12,
paddingVertical: 12,
gap: 4,
},
customPromptIcon: {
marginTop: 4,
},
customPromptInput: {
flex: 1,
fontSize: 16,
lineHeight: 24,
color: theme.centerChannelColor,
padding: 0,
margin: 0,
includeFontPadding: false,
textAlignVertical: 'center',
verticalAlign: 'middle',
justifyContent: 'center',
},
optionsContainer: {
paddingTop: OPTIONS_PADDING,
},
bottomSheetContent: {
paddingTop: 10,
},
}));
const RewriteOptions = ({
closeButtonId,
componentId,
originalMessage,
updateValue,
}: Props) => {
const intl = useIntl();
const theme = useTheme();
const serverUrl = useServerUrl();
const styles = getStyleSheet(theme);
const insets = useSafeAreaInsets();
const {startRewrite} = useRewrite();
const [customPrompt, setCustomPrompt] = useState('');
const agents = useAgents(serverUrl);
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
const textInputRef = useRef<TextInput>(null);
// Set default agent when agents list changes
useEffect(() => {
if (agents.length > 0) {
setSelectedAgent((current) => current || agents[0]);
}
}, [agents]);
const closeBottomSheet = useCallback(async () => {
await dismissBottomSheet(Screens.AGENTS_REWRITE_OPTIONS);
}, []);
useNavButtonPressed(closeButtonId, componentId, closeBottomSheet, []);
useAndroidHardwareBackHandler(componentId, closeBottomSheet);
const handleRewriteSuccess = useCallback((rewrittenText: string) => {
updateValue(rewrittenText);
}, [updateValue]);
const handleRewriteError = useCallback((errorMsg: string) => {
Alert.alert(
intl.formatMessage(messages.errorTitle),
errorMsg,
[{
text: intl.formatMessage(messages.errorOk),
}],
);
}, [intl]);
const handleRewrite = useCallback(async (action: RewriteAction, prompt?: string) => {
Keyboard.dismiss();
// Determine if we're generating new content or editing existing content
const isGenerating = !originalMessage || !originalMessage.trim();
// For content generation, we need a custom prompt
if (isGenerating && (!prompt || !prompt.trim())) {
logWarning('[RewriteOptions] Content generation called without prompt');
return;
}
// For generation, pass empty string as message and use prompt; for editing, use original message
const messageToProcess = isGenerating ? '' : originalMessage;
const agentId = selectedAgent?.id;
const triggerRewrite = () => {
startRewrite(
serverUrl,
messageToProcess,
action,
prompt,
agentId,
handleRewriteSuccess,
handleRewriteError,
);
};
// Close the bottom sheet first, then start rewrite
try {
await closeBottomSheet();
triggerRewrite();
} catch (e) {
logWarning('[RewriteOptions] Error closing bottom sheet:', e);
// Still try to start the rewrite even if sheet close failed
triggerRewrite();
}
}, [originalMessage, serverUrl, closeBottomSheet, selectedAgent, startRewrite, handleRewriteSuccess, handleRewriteError]);
// Determine if we're in generation mode (empty original message means user wants to generate new content)
const isInGenerationMode = !originalMessage || !originalMessage.trim();
// Auto-focus the text input when in generation mode
useEffect(() => {
if (isInGenerationMode) {
// Small delay to ensure the bottom sheet has finished animating
const timer = setTimeout(() => {
textInputRef.current?.focus();
}, 300);
return () => clearTimeout(timer);
}
return undefined;
}, [isInGenerationMode]);
const handleCustomPromptSubmit = useCallback(() => {
Keyboard.dismiss();
if (customPrompt && customPrompt.trim()) {
handleRewrite('custom', customPrompt);
}
}, [customPrompt, handleRewrite]);
const isTablet = useIsTablet();
const handleOpenAgentSelector = useCallback(() => {
const title = isTablet ? intl.formatMessage(messages.agentSelectorTitle) : '';
openAsBottomSheet({
closeButtonId: 'close-agent-selector',
screen: Screens.AGENTS_SELECTOR,
theme,
title,
props: {
closeButtonId: 'close-agent-selector',
agents,
selectedAgentId: selectedAgent?.id || '',
onSelectAgent: (agent: Agent) => {
setSelectedAgent(agent);
},
},
});
}, [theme, intl, agents, selectedAgent, isTablet]);
const snapPoints = useMemo(() => {
const paddingBottom = 10;
// Add agent selector height if multiple agents available
const agentSelectorHeight = agents.length > 1 ? ITEM_HEIGHT : 0;
// Use the same height for both generation and editing modes
const optionsHeight = OPTIONS_PADDING + bottomSheetSnapPoint(6, ITEM_HEIGHT);
const COMPONENT_HEIGHT = agentSelectorHeight + CUSTOM_PROMPT_INPUT_HEIGHT + optionsHeight + paddingBottom + insets.bottom;
return [1, COMPONENT_HEIGHT];
}, [agents.length, insets.bottom]);
const renderContent = useCallback(() => (
<View style={styles.container}>
<View style={styles.headerContainer}>
{agents.length > 1 && (
<OptionItem
label={intl.formatMessage(messages.selectedAgent)}
info={selectedAgent?.displayName || intl.formatMessage(messages.noAgentSelected)}
action={handleOpenAgentSelector}
type='arrow'
testID='ai_rewrite.select_agent'
/>
)}
<View style={styles.customPromptContainer}>
<View style={styles.customPromptInputWrapper}>
<CompassIcon
name='creation-outline'
size={20}
color={changeOpacity(theme.centerChannelColor, 0.64)}
style={styles.customPromptIcon}
/>
<TextInput
ref={textInputRef}
style={styles.customPromptInput}
placeholder={intl.formatMessage(isInGenerationMode ? messages.generatePrompt : messages.customPrompt)}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.64)}
value={customPrompt}
onChangeText={setCustomPrompt}
onSubmitEditing={handleCustomPromptSubmit}
returnKeyType='send'
multiline={false}
autoCapitalize='none'
/>
</View>
</View>
</View>
{!isInGenerationMode && (
<View style={styles.optionsContainer}>
{options.map((option) => (
<OptionItem
key={option.action}
label={intl.formatMessage(option.message)}
icon={option.icon}
action={() => handleRewrite(option.action)}
type='default'
testID={`ai_rewrite.option.${option.action}`}
/>
))}
</View>
)}
</View>
), [styles, agents, intl, selectedAgent, handleOpenAgentSelector, theme, isInGenerationMode, customPrompt, handleCustomPromptSubmit, handleRewrite]);
return (
<BottomSheet
renderContent={renderContent}
closeButtonId={closeButtonId}
componentId={Screens.AGENTS_REWRITE_OPTIONS}
initialSnapIndex={1}
snapPoints={snapPoints}
scrollable={true}
keyboardBehavior='fillParent'
keyboardBlurBehavior='none'
testID='ai_rewrite_options'
contentStyle={styles.bottomSheetContent}
/>
);
};
export default RewriteOptions;

View file

@ -2,3 +2,4 @@
// See LICENSE.txt for license information.
export {default as streamingStore, useStreamingState} from './streaming_store';
export {default as rewriteStore, type RewriteState} from './rewrite_store';

View file

@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BehaviorSubject} from 'rxjs';
import type {Agent} from '@agents/types';
/**
* Rewrite processing state
*/
export interface RewriteState {
isProcessing: boolean;
serverUrl: string;
}
/**
* Store for managing rewrite-related ephemeral state
*/
class RewriteStore {
private agentsSubjects: Map<string, BehaviorSubject<Agent[]>> = new Map();
private rewriteState = new BehaviorSubject<RewriteState>({isProcessing: false, serverUrl: ''});
/**
* Get or create a BehaviorSubject for agents per server
*/
private getAgentsSubject(serverUrl: string): BehaviorSubject<Agent[]> {
let subject = this.agentsSubjects.get(serverUrl);
if (!subject) {
subject = new BehaviorSubject<Agent[]>([]);
this.agentsSubjects.set(serverUrl, subject);
}
return subject;
}
// =========================================================================
// Agents Management
// =========================================================================
/**
* Observe agents for a server (reactive)
*/
observeAgents(serverUrl: string) {
return this.getAgentsSubject(serverUrl).asObservable();
}
/**
* Set agents for a server
*/
setAgents(serverUrl: string, agents: Agent[]) {
this.getAgentsSubject(serverUrl).next(agents);
}
/**
* Get current agents for a server (synchronous)
*/
getAgents(serverUrl: string): Agent[] {
return this.getAgentsSubject(serverUrl).getValue();
}
// =========================================================================
// Rewrite Processing State
// =========================================================================
/**
* Observe rewrite processing state (reactive)
*/
observeRewriteState() {
return this.rewriteState.asObservable();
}
/**
* Set rewrite processing state
*/
setRewriteProcessing(isProcessing: boolean, serverUrl: string) {
this.rewriteState.next({isProcessing, serverUrl});
}
/**
* Get current rewrite state (synchronous)
*/
getRewriteState(): RewriteState {
return this.rewriteState.getValue();
}
/**
* Check if currently processing a rewrite
*/
isRewriteProcessing(): boolean {
return this.rewriteState.getValue().isProcessing;
}
}
// Singleton instance
const rewriteStore = new RewriteStore();
export default rewriteStore;

View file

@ -45,3 +45,20 @@ export type AgentsStatusResponse = {
available: boolean;
reason?: string;
};
/**
* Request payload for rewriting a message
*/
export type RewriteRequest = {
agent_id?: string;
message: string;
action?: string;
custom_prompt?: string;
};
/**
* Response from a rewrite request
*/
export type RewriteResponse = {
rewritten_text: string;
};

View file

@ -67,3 +67,13 @@ export interface StreamingState {
annotations: Annotation[]; // Citations/annotations for the post
}
// ============================================================================
// Rewrite Types
// ============================================================================
export type {Agent} from './api';
/**
* Available rewrite action types
*/
export type RewriteAction = 'shorten' | 'elaborate' | 'improve_writing' | 'fix_spelling' | 'simplify' | 'summarize' | 'custom';

View file

@ -38,6 +38,8 @@ type Props = {
enableDynamicSizing?: boolean;
testID?: string;
scrollable?: boolean;
keyboardBehavior?: 'extend' | 'fillParent' | 'interactive';
keyboardBlurBehavior?: 'none' | 'restore';
}
const PADDING_TOP_MOBILE = 20;
@ -101,6 +103,8 @@ const BottomSheet = ({
testID,
enableDynamicSizing = false,
scrollable = false,
keyboardBehavior = 'extend',
keyboardBlurBehavior = 'restore',
}: Props) => {
const reducedMotion = useReducedMotion();
const sheetRef = useRef<BottomSheetM>(null);
@ -266,8 +270,8 @@ const BottomSheet = ({
style={styles.bottomSheet}
backgroundStyle={bottomSheetBackgroundStyle}
footerComponent={footerComponent}
keyboardBehavior='extend'
keyboardBlurBehavior='restore'
keyboardBehavior={keyboardBehavior}
keyboardBlurBehavior={keyboardBlurBehavior}
onClose={close}
bottomInset={insets.bottom}
enableDynamicSizing={enableDynamicSizing}

View file

@ -9,6 +9,7 @@ import React from 'react';
import AvailableScreens from '@constants/screens';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import {logError} from '@utils/log';
import EditProfile from './edit_profile';
@ -78,8 +79,7 @@ const mockFetchCustomProfileAttributes = jest.fn();
// Add mock for updateCustomProfileAttributes to track calls
const mockUpdateCustomProfileAttributes = jest.fn();
// Mock logError function
const mockLogError = jest.fn();
const mockLogError = logError as jest.Mock;
jest.mock('@actions/remote/custom_profile', () => ({
fetchCustomProfileAttributes: (...args: any[]) => mockFetchCustomProfileAttributes(...args),
@ -87,7 +87,10 @@ jest.mock('@actions/remote/custom_profile', () => ({
}));
jest.mock('@utils/log', () => ({
logError: (...args: any[]) => mockLogError(...args),
logError: jest.fn(),
logWarning: jest.fn(),
logDebug: jest.fn(),
logInfo: jest.fn(),
}));
jest.mock('@actions/remote/user', () => ({

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {loadAgentsScreen} from '@agents/screens';
import {Provider as EMMProvider} from '@mattermost/react-native-emm';
import React, {type ComponentType} from 'react';
import {IntlProvider} from 'react-intl';
@ -316,6 +317,10 @@ Navigation.setLazyComponentRegistrator((screenName) => {
break;
}
if (!screen) {
screen = loadAgentsScreen(screenName);
}
if (!screen) {
screen = loadPlaybooksScreen(screenName);
}

View file

@ -20,7 +20,10 @@ jest.mock('react-native-exception-handler', () => ({
}));
jest.mock('@utils/log', () => ({
logError: jest.fn(),
logWarning: jest.fn(() => ''),
logDebug: jest.fn(),
logInfo: jest.fn(),
}));
describe('JavascriptAndNativeErrorHandler', () => {

View file

@ -46,6 +46,21 @@
"agents.tool_call.response": "Response",
"agents.tool_call.status.rejected": "Rejected",
"agents.tool_call.submitting": "Submitting...",
"ai_rewrite.agent_selector_title": "Select AI Agent",
"ai_rewrite.custom_prompt": "Ask AI to edit message...",
"ai_rewrite.elaborate": "Elaborate",
"ai_rewrite.error.ok": "OK",
"ai_rewrite.error.title": "AI Rewrite Error",
"ai_rewrite.fix_spelling": "Fix spelling and grammar",
"ai_rewrite.generate_prompt": "Ask agents to create a message",
"ai_rewrite.improve_writing": "Improve writing",
"ai_rewrite.no_agent_selected": "None",
"ai_rewrite.rewriting": "Rewriting...",
"ai_rewrite.selected_agent": "Selected Agent",
"ai_rewrite.shorten": "Shorten",
"ai_rewrite.simplify": "Simplify",
"ai_rewrite.summarize": "Summarize",
"ai_rewrite.title": "AI Rewrite",
"alert.channel_deleted.description": "The channel {displayName} has been archived.",
"alert.channel_deleted.title": "Archived channel",
"alert.push_proxy_error.description": "Due to the configuration of this server, notifications cannot be received in the mobile app. Contact your system admin for more information.",

View file

@ -2746,6 +2746,6 @@ SPEC CHECKSUMS:
WatermelonDB: 4c846c8cb94eef3cba90fa034d15310163226703
Yoga: 2b02f3f767761bb4ffd25b1bb56fd264be57bd6b
PODFILE CHECKSUM: f3442fc727bf29ef7e4eda969e4e598128801d4a
PODFILE CHECKSUM: 606164aa1d6839d4676945c6571456a11452e595
COCOAPODS: 1.16.1

2
package-lock.json generated
View file

@ -6,7 +6,7 @@
"packages": {
"": {
"name": "mattermost-mobile",
"version": "2.36.0",
"version": "2.38.0",
"hasInstallScript": true,
"license": "Apache 2.0",
"dependencies": {