From 714c3dc769aded9c436479b9eb88027f6ac90b87 Mon Sep 17 00:00:00 2001 From: Felipe Martin <812088+fmartingr@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:48:57 +0100 Subject: [PATCH] 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> --- app/actions/remote/entry/app.ts | 5 + .../draft_scheduled_post.test.tsx | 6 + app/components/formatted_date/index.test.tsx | 3 + .../draft_input/draft_input.test.tsx | 6 + .../post_draft/draft_input/draft_input.tsx | 3 +- .../post_draft/post_input/post_input.tsx | 81 ++-- .../post_draft/quick_actions/index.ts | 11 +- .../quick_actions/quick_actions.test.tsx | 1 + .../quick_actions/quick_actions.tsx | 13 +- .../post_draft/status_indicator/index.tsx | 45 +++ app/components/post_draft/typing/index.tsx | 29 +- .../post_list/post/header/header.tsx | 139 ++++--- app/constants/screens.ts | 5 + app/hooks/gallery.ts | 3 + app/hooks/handle_send_message.ts | 8 +- .../performance_metrics_batcher.test.ts | 3 + app/products/agents/actions/remote/agents.ts | 45 ++- app/products/agents/client/rest.ts | 32 +- .../components/ai_rewrite_action/index.tsx | 83 ++++ .../components/rewriting_indicator/index.tsx | 79 ++++ app/products/agents/constants/screens.ts | 10 + app/products/agents/hooks/index.ts | 5 + app/products/agents/hooks/use_agents.ts | 23 ++ app/products/agents/hooks/use_rewrite.ts | 185 +++++++++ app/products/agents/queries/agents.ts | 11 + .../screens/agent_selector/agent_item.tsx | 34 ++ .../agents/screens/agent_selector/index.tsx | 127 +++++++ app/products/agents/screens/index.ts | 17 + .../agents/screens/rewrite_options/index.tsx | 356 ++++++++++++++++++ app/products/agents/store/index.ts | 1 + app/products/agents/store/rewrite_store.ts | 96 +++++ app/products/agents/types/api.ts | 17 + app/products/agents/types/index.ts | 10 + app/screens/bottom_sheet/index.tsx | 8 +- .../edit_profile/edit_profile.test.tsx | 9 +- app/screens/index.tsx | 5 + app/utils/error_handling.test.ts | 3 + assets/base/i18n/en.json | 15 + ios/Podfile.lock | 2 +- package-lock.json | 2 +- 40 files changed, 1411 insertions(+), 125 deletions(-) create mode 100644 app/components/post_draft/status_indicator/index.tsx create mode 100644 app/products/agents/components/ai_rewrite_action/index.tsx create mode 100644 app/products/agents/components/rewriting_indicator/index.tsx create mode 100644 app/products/agents/constants/screens.ts create mode 100644 app/products/agents/hooks/index.ts create mode 100644 app/products/agents/hooks/use_agents.ts create mode 100644 app/products/agents/hooks/use_rewrite.ts create mode 100644 app/products/agents/queries/agents.ts create mode 100644 app/products/agents/screens/agent_selector/agent_item.tsx create mode 100644 app/products/agents/screens/agent_selector/index.tsx create mode 100644 app/products/agents/screens/index.ts create mode 100644 app/products/agents/screens/rewrite_options/index.tsx create mode 100644 app/products/agents/store/rewrite_store.ts diff --git a/app/actions/remote/entry/app.ts b/app/actions/remote/entry/app.ts index 877724489..63b02a869 100644 --- a/app/actions/remote/entry/app.ts +++ b/app/actions/remote/entry/app.ts @@ -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 {}; } diff --git a/app/components/draft_scheduled_post/draft_scheduled_post.test.tsx b/app/components/draft_scheduled_post/draft_scheduled_post.test.tsx index 4cb629dbd..5107e0391 100644 --- a/app/components/draft_scheduled_post/draft_scheduled_post.test.tsx +++ b/app/components/draft_scheduled_post/draft_scheduled_post.test.tsx @@ -29,6 +29,12 @@ jest.mock('@screens/navigation', () => ({ jest.mock('@context/server', () => ({ useServerUrl: jest.fn().mockReturnValue('https://server.com'), + withServerUrl: (Component: React.ComponentType) => (props: any) => ( + + ), })); jest.mock('@components/draft_scheduled_post_header', () => () => null); diff --git a/app/components/formatted_date/index.test.tsx b/app/components/formatted_date/index.test.tsx index feab8eb72..2147b38d0 100644 --- a/app/components/formatted_date/index.test.tsx +++ b/app/components/formatted_date/index.test.tsx @@ -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'); diff --git a/app/components/post_draft/draft_input/draft_input.test.tsx b/app/components/post_draft/draft_input/draft_input.test.tsx index b79ea281f..835d6dbf8 100644 --- a/app/components/post_draft/draft_input/draft_input.test.tsx +++ b/app/components/post_draft/draft_input/draft_input.test.tsx @@ -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) => (props: any) => ( + + ), })); jest.mock('@screens/navigation', () => ({ diff --git a/app/components/post_draft/draft_input/draft_input.tsx b/app/components/post_draft/draft_input/draft_input.tsx index e3209e075..301752937 100644 --- a/app/components/post_draft/draft_input/draft_input.tsx +++ b/app/components/post_draft/draft_input/draft_input.tsx @@ -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 ( <> + - ({ + 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 ( - + + + ); } diff --git a/app/components/post_draft/quick_actions/index.ts b/app/components/post_draft/quick_actions/index.ts index 9292c3013..d2a3b2bb1 100644 --- a/app/components/post_draft/quick_actions/index.ts +++ b/app/components/post_draft/quick_actions/index.ts @@ -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)))); diff --git a/app/components/post_draft/quick_actions/quick_actions.test.tsx b/app/components/post_draft/quick_actions/quick_actions.test.tsx index 03842d36f..431e57845 100644 --- a/app/components/post_draft/quick_actions/quick_actions.test.tsx +++ b/app/components/post_draft/quick_actions/quick_actions.test.tsx @@ -19,6 +19,7 @@ describe('Quick Actions', () => { testID: 'test-quick-actions', canUploadFiles: true, fileCount: 0, + isAgentsEnabled: true, isPostPriorityEnabled: true, canShowPostPriority: true, canShowSlashCommands: true, diff --git a/app/components/post_draft/quick_actions/quick_actions.tsx b/app/components/post_draft/quick_actions/quick_actions.tsx index 3113dbe42..4ee7bbd91 100644 --- a/app/components/post_draft/quick_actions/quick_actions.tsx +++ b/app/components/post_draft/quick_actions/quick_actions.tsx @@ -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 && ( + + )} {isPostPriorityEnabled && canShowPostPriority && ( ; +} + +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 ( + + {children} + + ); +} + +export default React.memo(StatusIndicator); + diff --git a/app/components/post_draft/typing/index.tsx b/app/components/post_draft/typing/index.tsx index 349ffb286..7ac57ab30 100644 --- a/app/components/post_draft/typing/index.tsx +++ b/app/components/post_draft/typing/index.tsx @@ -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>([]); const timeoutToDisappear = useRef(); 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 ( - + {renderTyping()} - + ); } export default React.memo(Typing); - diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx index f57f82d6e..b7e30bd1b 100644 --- a/app/components/post_list/post/header/header.tsx +++ b/app/components/post_list/post/header/header.tsx @@ -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) && - - } + {(!isSystemPost || isAutoResponse) && ( + + )} - {isChannelAutotranslated && post.type === '' && + {isChannelAutotranslated && post.type === '' && ( - } + )} {isEphemeral && ( )} {showPostPriority && post.metadata?.priority?.priority && ( - + )} - {showBoRIcon && + {showBoRIcon && ( - } - { - Boolean(borExpireAt) && + )} + {Boolean(borExpireAt) && ( - } - {!isCRTEnabled && showReply && commentCount > 0 && + )} + {!isCRTEnabled && showReply && commentCount > 0 && ( - } + )} - {Boolean(rootAuthorDisplayName) && location === CHANNEL && - - } + {Boolean(rootAuthorDisplayName) && location === CHANNEL && ( + + )} ); }; diff --git a/app/constants/screens.ts b/app/constants/screens.ts index dafbc3e96..4d8ab628e 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -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([ @@ -214,6 +217,8 @@ export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set([ ]); export const SCREENS_AS_BOTTOM_SHEET = new Set([ + AGENTS_SCREENS.AGENTS_SELECTOR, + AGENTS_SCREENS.AGENTS_REWRITE_OPTIONS, ATTACHMENT_OPTIONS, BOTTOM_SHEET, DRAFT_SCHEDULED_POST_OPTIONS, diff --git a/app/hooks/gallery.ts b/app/hooks/gallery.ts index 1a346add5..a2ec98c51 100644 --- a/app/hooks/gallery.ts +++ b/app/hooks/gallery.ts @@ -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 = () => { diff --git a/app/hooks/handle_send_message.ts b/app/hooks/handle_send_message.ts index 5676a4813..8b79d355e 100644 --- a/app/hooks/handle_send_message.ts +++ b/app/hooks/handle_send_message.ts @@ -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 diff --git a/app/managers/performance_metrics_manager/performance_metrics_batcher.test.ts b/app/managers/performance_metrics_manager/performance_metrics_batcher.test.ts index a96a120cf..39d4fb493 100644 --- a/app/managers/performance_metrics_manager/performance_metrics_batcher.test.ts +++ b/app/managers/performance_metrics_manager/performance_metrics_batcher.test.ts @@ -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 { diff --git a/app/products/agents/actions/remote/agents.ts b/app/products/agents/actions/remote/agents.ts index 664eb467d..69e3d0f97 100644 --- a/app/products/agents/actions/remote/agents.ts +++ b/app/products/agents/actions/remote/agents.ts @@ -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)}; } } diff --git a/app/products/agents/client/rest.ts b/app/products/agents/client/rest.ts index c40ad6543..ca82006d4 100644 --- a/app/products/agents/client/rest.ts +++ b/app/products/agents/client/rest.ts @@ -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; submitToolApproval: (postId: string, acceptedToolIds: string[]) => Promise; + + // Rewrite methods + getRewrittenMessage: (message: string, action?: string, customPrompt?: string, agentId?: string) => Promise; getAgentsStatus: () => Promise; } @@ -85,6 +88,33 @@ const ClientAgents = (superclass: any) => class extends superclass { ); }; + // ========================================================================= + // Rewrite Methods + // ========================================================================= + + getRewrittenMessage = async (message: string, action?: string, customPrompt?: string, agentId?: string): Promise => { + 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 => { return this.doFetch( `${this.urlVersion}/agents/status`, diff --git a/app/products/agents/components/ai_rewrite_action/index.tsx b/app/products/agents/components/ai_rewrite_action/index.tsx new file mode 100644 index 000000000..dafbe4806 --- /dev/null +++ b/app/products/agents/components/ai_rewrite_action/index.tsx @@ -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 ( + + + + ); +} diff --git a/app/products/agents/components/rewriting_indicator/index.tsx b/app/products/agents/components/rewriting_indicator/index.tsx new file mode 100644 index 000000000..e5b1e5c78 --- /dev/null +++ b/app/products/agents/components/rewriting_indicator/index.tsx @@ -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 ( + + + + + {intl.formatMessage({ + id: 'ai_rewrite.rewriting', + defaultMessage: 'Rewriting...', + })} + + + + ); +} + +export default React.memo(RewritingIndicator); diff --git a/app/products/agents/constants/screens.ts b/app/products/agents/constants/screens.ts new file mode 100644 index 000000000..492e2a883 --- /dev/null +++ b/app/products/agents/constants/screens.ts @@ -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; diff --git a/app/products/agents/hooks/index.ts b/app/products/agents/hooks/index.ts new file mode 100644 index 000000000..811dc50ed --- /dev/null +++ b/app/products/agents/hooks/index.ts @@ -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'; diff --git a/app/products/agents/hooks/use_agents.ts b/app/products/agents/hooks/use_agents.ts new file mode 100644 index 000000000..79d65c3f0 --- /dev/null +++ b/app/products/agents/hooks/use_agents.ts @@ -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( + () => rewriteStore.getAgents(serverUrl), + ); + + useEffect(() => { + const subscription = rewriteStore.observeAgents(serverUrl).subscribe(setAgents); + return () => subscription.unsubscribe(); + }, [serverUrl]); + + return agents; +} diff --git a/app/products/agents/hooks/use_rewrite.ts b/app/products/agents/hooks/use_rewrite.ts new file mode 100644 index 000000000..5ec7d23d0 --- /dev/null +++ b/app/products/agents/hooks/use_rewrite.ts @@ -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 | null>(null); + const timeoutRef = useRef(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((_, 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( + () => rewriteStore.getRewriteState(), + ); + + useEffect(() => { + const subscription = rewriteStore.observeRewriteState().subscribe(setState); + return () => subscription.unsubscribe(); + }, []); + + return state; +} diff --git a/app/products/agents/queries/agents.ts b/app/products/agents/queries/agents.ts new file mode 100644 index 000000000..ee9292ed1 --- /dev/null +++ b/app/products/agents/queries/agents.ts @@ -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), + ); +}; diff --git a/app/products/agents/screens/agent_selector/agent_item.tsx b/app/products/agents/screens/agent_selector/agent_item.tsx new file mode 100644 index 000000000..c93be0ac5 --- /dev/null +++ b/app/products/agents/screens/agent_selector/agent_item.tsx @@ -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 ( + + ); +}; + +export default AgentItem; diff --git a/app/products/agents/screens/agent_selector/index.tsx b/app/products/agents/screens/agent_selector/index.tsx new file mode 100644 index 000000000..a7235d99f --- /dev/null +++ b/app/products/agents/screens/agent_selector/index.tsx @@ -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) => ( + + ), [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 = [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 = () => ( + + + + ); + + return ( + + ); +}; + +export default AgentSelector; diff --git a/app/products/agents/screens/index.ts b/app/products/agents/screens/index.ts new file mode 100644 index 000000000..d8701f222 --- /dev/null +++ b/app/products/agents/screens/index.ts @@ -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; + } +} diff --git a/app/products/agents/screens/rewrite_options/index.tsx b/app/products/agents/screens/rewrite_options/index.tsx new file mode 100644 index 000000000..0f6401fea --- /dev/null +++ b/app/products/agents/screens/rewrite_options/index.tsx @@ -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(null); + const textInputRef = useRef(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(() => ( + + + {agents.length > 1 && ( + + )} + + + + + + + + + + {!isInGenerationMode && ( + + {options.map((option) => ( + handleRewrite(option.action)} + type='default' + testID={`ai_rewrite.option.${option.action}`} + /> + ))} + + )} + + ), [styles, agents, intl, selectedAgent, handleOpenAgentSelector, theme, isInGenerationMode, customPrompt, handleCustomPromptSubmit, handleRewrite]); + + return ( + + ); +}; + +export default RewriteOptions; diff --git a/app/products/agents/store/index.ts b/app/products/agents/store/index.ts index de963ca88..8ed1ab525 100644 --- a/app/products/agents/store/index.ts +++ b/app/products/agents/store/index.ts @@ -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'; diff --git a/app/products/agents/store/rewrite_store.ts b/app/products/agents/store/rewrite_store.ts new file mode 100644 index 000000000..3b8cd274b --- /dev/null +++ b/app/products/agents/store/rewrite_store.ts @@ -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> = new Map(); + private rewriteState = new BehaviorSubject({isProcessing: false, serverUrl: ''}); + + /** + * Get or create a BehaviorSubject for agents per server + */ + private getAgentsSubject(serverUrl: string): BehaviorSubject { + let subject = this.agentsSubjects.get(serverUrl); + if (!subject) { + subject = new BehaviorSubject([]); + 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; diff --git a/app/products/agents/types/api.ts b/app/products/agents/types/api.ts index 379ea2874..e5b779994 100644 --- a/app/products/agents/types/api.ts +++ b/app/products/agents/types/api.ts @@ -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; +}; diff --git a/app/products/agents/types/index.ts b/app/products/agents/types/index.ts index 49c214753..70d128d0d 100644 --- a/app/products/agents/types/index.ts +++ b/app/products/agents/types/index.ts @@ -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'; diff --git a/app/screens/bottom_sheet/index.tsx b/app/screens/bottom_sheet/index.tsx index ea0589d39..cd75df6c2 100644 --- a/app/screens/bottom_sheet/index.tsx +++ b/app/screens/bottom_sheet/index.tsx @@ -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(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} diff --git a/app/screens/edit_profile/edit_profile.test.tsx b/app/screens/edit_profile/edit_profile.test.tsx index ca1fca161..a85dc3082 100644 --- a/app/screens/edit_profile/edit_profile.test.tsx +++ b/app/screens/edit_profile/edit_profile.test.tsx @@ -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', () => ({ diff --git a/app/screens/index.tsx b/app/screens/index.tsx index cc52c4640..b5bc2f870 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -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); } diff --git a/app/utils/error_handling.test.ts b/app/utils/error_handling.test.ts index b898d1f55..c0acae2e7 100644 --- a/app/utils/error_handling.test.ts +++ b/app/utils/error_handling.test.ts @@ -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', () => { diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index ea84011cd..d0df08973 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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.", diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a17621f84..50742c578 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2746,6 +2746,6 @@ SPEC CHECKSUMS: WatermelonDB: 4c846c8cb94eef3cba90fa034d15310163226703 Yoga: 2b02f3f767761bb4ffd25b1bb56fd264be57bd6b -PODFILE CHECKSUM: f3442fc727bf29ef7e4eda969e4e598128801d4a +PODFILE CHECKSUM: 606164aa1d6839d4676945c6571456a11452e595 COCOAPODS: 1.16.1 diff --git a/package-lock.json b/package-lock.json index 7b4b57132..f38f13038 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "mattermost-mobile", - "version": "2.36.0", + "version": "2.38.0", "hasInstallScript": true, "license": "Apache 2.0", "dependencies": {