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:
parent
1db5cd456c
commit
714c3dc769
40 changed files with 1411 additions and 125 deletions
|
|
@ -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 {};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))));
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ describe('Quick Actions', () => {
|
|||
testID: 'test-quick-actions',
|
||||
canUploadFiles: true,
|
||||
fileCount: 0,
|
||||
isAgentsEnabled: true,
|
||||
isPostPriorityEnabled: true,
|
||||
canShowPostPriority: true,
|
||||
canShowSlashCommands: true,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
45
app/components/post_draft/status_indicator/index.tsx
Normal file
45
app/components/post_draft/status_indicator/index.tsx
Normal 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);
|
||||
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
83
app/products/agents/components/ai_rewrite_action/index.tsx
Normal file
83
app/products/agents/components/ai_rewrite_action/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
app/products/agents/components/rewriting_indicator/index.tsx
Normal file
79
app/products/agents/components/rewriting_indicator/index.tsx
Normal 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);
|
||||
10
app/products/agents/constants/screens.ts
Normal file
10
app/products/agents/constants/screens.ts
Normal 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;
|
||||
5
app/products/agents/hooks/index.ts
Normal file
5
app/products/agents/hooks/index.ts
Normal 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';
|
||||
23
app/products/agents/hooks/use_agents.ts
Normal file
23
app/products/agents/hooks/use_agents.ts
Normal 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;
|
||||
}
|
||||
185
app/products/agents/hooks/use_rewrite.ts
Normal file
185
app/products/agents/hooks/use_rewrite.ts
Normal 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;
|
||||
}
|
||||
11
app/products/agents/queries/agents.ts
Normal file
11
app/products/agents/queries/agents.ts
Normal 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),
|
||||
);
|
||||
};
|
||||
34
app/products/agents/screens/agent_selector/agent_item.tsx
Normal file
34
app/products/agents/screens/agent_selector/agent_item.tsx
Normal 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;
|
||||
127
app/products/agents/screens/agent_selector/index.tsx
Normal file
127
app/products/agents/screens/agent_selector/index.tsx
Normal 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;
|
||||
17
app/products/agents/screens/index.ts
Normal file
17
app/products/agents/screens/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
356
app/products/agents/screens/rewrite_options/index.tsx
Normal file
356
app/products/agents/screens/rewrite_options/index.tsx
Normal 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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
96
app/products/agents/store/rewrite_store.ts
Normal file
96
app/products/agents/store/rewrite_store.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
2
package-lock.json
generated
|
|
@ -6,7 +6,7 @@
|
|||
"packages": {
|
||||
"": {
|
||||
"name": "mattermost-mobile",
|
||||
"version": "2.36.0",
|
||||
"version": "2.38.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache 2.0",
|
||||
"dependencies": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue