Skip to content

Commit 0c675ec

Browse files
authored
Agents posts on mobile (#9317)
* Agents streaming * Fix bug and lint * Add reasoning summaries to mobile agents * Add tool call approval UI for mobile agents * Add citations and annotations support for mobile agents Implements Phase 4 from the agents mobile plan: - WebSocket event handling for annotations control signal - CitationsList component displaying sources below agent responses - Collapsible sources section with source count - Each citation shows title, domain, and opens URL on tap - Citations persisted in post.props.annotations - Touch-optimized UI with 44pt minimum tap targets * Add stop and regenerate controls for mobile agents * Add tests * Fix tool approval buttons not updating after accept/reject - Remove optimistic WebSocket event approach that didn't work - Clear component streaming state on ENDED event to force switch to persisted data - Remove delays in streaming store cleanup (500ms and 100ms) - POST_EDITED event now properly updates UI for both accept and reject - Remove debug console.log statements - Fix ESLint issues (unused vars, nested callbacks, nested ternary) * Refactor agents code: remove unused client mixin, fix bugs, and simplify logic * Fixes * Add CLAUDE.md * Review feedback. * Review feedback inline functions * Learnings * Address review feedback: StyleSheet.create, parent mount checks, utils * Move to observables * Last style fix * Style tweaks
1 parent f243f1c commit 0c675ec

File tree

36 files changed

+3389
-2
lines changed

36 files changed

+3389
-2
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,6 @@ libraries/**/**/.build
123123
# Android sounds
124124
android/app/src/main/res/raw/*
125125
.aider*
126+
127+
# Claude Code
128+
.claude/settings.local.json

CLAUDE.md

Lines changed: 366 additions & 0 deletions
Large diffs are not rendered by default.

app/actions/websocket/event.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
22
// See LICENSE.txt for license information.
33

4+
import {handleAgentPostUpdate} from '@agents/actions/websocket';
5+
46
import * as bookmark from '@actions/local/channel_bookmark';
57
import * as scheduledPost from '@actions/websocket/scheduled_post';
68
import * as calls from '@calls/connection/websocket_event_handlers';
@@ -301,6 +303,11 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
301303
case WebsocketEvents.CUSTOM_PROFILE_ATTRIBUTES_FIELD_DELETED:
302304
handleCustomProfileAttributesFieldDeletedEvent(serverUrl, msg);
303305
break;
306+
307+
// Agents
308+
case WebsocketEvents.AGENTS_POST_UPDATE:
309+
handleAgentPostUpdate(msg);
310+
break;
304311
}
305312
handlePlaybookEvents(serverUrl, msg);
306313
}

app/actions/websocket/posts.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
22
// See LICENSE.txt for license information.
33

4+
import streamingStore from '@agents/store/streaming_store';
5+
import {isAgentPost} from '@agents/utils';
46
import {DeviceEventEmitter} from 'react-native';
57

68
import {storeMyChannelsForTeam, markChannelAsUnread, markChannelAsViewed, updateLastPostAt} from '@actions/local/channel';
@@ -267,7 +269,11 @@ export async function handlePostEdited(serverUrl: string, msg: WebSocketMessage)
267269
});
268270
models.push(...postModels);
269271

270-
operator.batchRecords(models, 'handlePostEdited');
272+
await operator.batchRecords(models, 'handlePostEdited');
273+
274+
if (isAgentPost(post)) {
275+
streamingStore.removePost(post.id);
276+
}
271277
}
272278

273279
export async function handlePostDeleted(serverUrl: string, msg: WebSocketMessage) {

app/client/rest/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
22
// See LICENSE.txt for license information.
33

4+
import ClientAgents, {type ClientAgentsMix} from '@agents/client/rest';
5+
46
import ClientCalls, {type ClientCallsMix} from '@calls/client/rest';
57
import ClientPlugins, {type ClientPluginsMix} from '@client/rest/plugins';
68
import ClientPlaybooks, {type ClientPlaybooksMix} from '@playbooks/client/rest';
@@ -30,6 +32,7 @@ import ClientUsers, {type ClientUsersMix} from './users';
3032
import type {APIClientInterface} from '@mattermost/react-native-network-client';
3133

3234
interface Client extends ClientBase,
35+
ClientAgentsMix,
3336
ClientAppsMix,
3437
ClientCategoriesMix,
3538
ClientChannelsMix,
@@ -57,6 +60,7 @@ interface Client extends ClientBase,
5760
}
5861

5962
class Client extends mix(ClientBase).with(
63+
ClientAgents,
6064
ClientApps,
6165
ClientCategories,
6266
ClientChannels,

app/components/post_list/post/post.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
22
// See LICENSE.txt for license information.
33

4+
import AgentPost from '@agents/components/agent_post';
5+
import {isAgentPost} from '@agents/utils';
46
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
57
import {useIntl} from 'react-intl';
68
import {Keyboard, Platform, type StyleProp, View, type ViewStyle, TouchableHighlight} from 'react-native';
@@ -157,6 +159,7 @@ const Post = ({
157159
const isFailed = isPostFailed(post);
158160
const isSystemPost = isSystemMessage(post);
159161
const isCallsPost = isCallsCustomMessage(post);
162+
const isAgentPostType = isAgentPost(post);
160163
const hasBeenDeleted = (post.deleteAt !== 0);
161164
const isWebHook = isFromWebhook(post);
162165
const hasSameRoot = useMemo(() => {
@@ -251,6 +254,8 @@ const Post = ({
251254
clearTimeout(t);
252255
}
253256
};
257+
258+
// eslint-disable-next-line react-hooks/exhaustive-deps -- Timer only needs to reset when post.id changes, not on other prop updates
254259
}, [post.id]);
255260

256261
useEffect(() => {
@@ -264,6 +269,8 @@ const Post = ({
264269

265270
PerformanceMetricsManager.finishLoad(location === 'Thread' ? 'THREAD' : 'CHANNEL', serverUrl);
266271
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl);
272+
273+
// eslint-disable-next-line react-hooks/exhaustive-deps -- Performance metrics should only run once on mount
267274
}, []);
268275

269276
const highlightSaved = isSaved && !skipSavedHeader;
@@ -356,6 +363,14 @@ const Post = ({
356363
joiningChannelId={null}
357364
/>
358365
);
366+
} else if (isAgentPostType && !hasBeenDeleted) {
367+
body = (
368+
<AgentPost
369+
post={post}
370+
currentUserId={currentUser?.id}
371+
location={location}
372+
/>
373+
);
359374
} else {
360375
body = (
361376
<Body

app/constants/post.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export const PostTypes = {
3535
SYSTEM_AUTO_RESPONDER: 'system_auto_responder',
3636
CUSTOM_CALLS: 'custom_calls',
3737
CUSTOM_CALLS_RECORDING: 'custom_calls_recording',
38+
CUSTOM_LLMBOT: 'custom_llmbot',
39+
CUSTOM_LLM_POSTBACK: 'custom_llm_postback',
3840
} as const;
3941

4042
export const PostPriorityColors = {

app/constants/snack_bar.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import keyMirror from '@utils/key_mirror';
77

88
export const SNACK_BAR_TYPE = keyMirror({
99
ADD_CHANNEL_MEMBERS: null,
10+
AGENT_STOP_ERROR: null,
11+
AGENT_REGENERATE_ERROR: null,
12+
AGENT_TOOL_APPROVAL_ERROR: null,
1013
CODE_COPIED: null,
1114
FAVORITE_CHANNEL: null,
1215
FOLLOW_THREAD: null,
@@ -46,6 +49,18 @@ const messages = defineMessages({
4649
id: 'snack.bar.channel.members.added',
4750
defaultMessage: '{numMembers, number} {numMembers, plural, one {member} other {members}} added',
4851
},
52+
AGENT_STOP_ERROR: {
53+
id: 'snack.bar.agent.stop.error',
54+
defaultMessage: 'Failed to stop generation',
55+
},
56+
AGENT_REGENERATE_ERROR: {
57+
id: 'snack.bar.agent.regenerate.error',
58+
defaultMessage: 'Failed to regenerate response',
59+
},
60+
AGENT_TOOL_APPROVAL_ERROR: {
61+
id: 'snack.bar.agent.tool.approval.error',
62+
defaultMessage: 'Failed to submit tool approval',
63+
},
4964
CODE_COPIED: {
5065
id: 'snack.bar.code.copied',
5166
defaultMessage: 'Code copied to clipboard',
@@ -110,6 +125,24 @@ export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
110125
iconName: 'check',
111126
canUndo: false,
112127
},
128+
AGENT_STOP_ERROR: {
129+
message: messages.AGENT_STOP_ERROR,
130+
iconName: 'alert-outline',
131+
canUndo: false,
132+
type: MESSAGE_TYPE.ERROR,
133+
},
134+
AGENT_REGENERATE_ERROR: {
135+
message: messages.AGENT_REGENERATE_ERROR,
136+
iconName: 'alert-outline',
137+
canUndo: false,
138+
type: MESSAGE_TYPE.ERROR,
139+
},
140+
AGENT_TOOL_APPROVAL_ERROR: {
141+
message: messages.AGENT_TOOL_APPROVAL_ERROR,
142+
iconName: 'alert-outline',
143+
canUndo: false,
144+
type: MESSAGE_TYPE.ERROR,
145+
},
113146
CODE_COPIED: {
114147
message: messages.CODE_COPIED,
115148
iconName: 'content-copy',

app/constants/websocket.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ const WebsocketEvents = {
105105
CUSTOM_PROFILE_ATTRIBUTES_FIELD_UPDATED: 'custom_profile_attributes_field_updated',
106106
CUSTOM_PROFILE_ATTRIBUTES_FIELD_CREATED: 'custom_profile_attributes_field_created',
107107
CUSTOM_PROFILE_ATTRIBUTES_FIELD_DELETED: 'custom_profile_attributes_field_deleted',
108+
109+
// Agents
110+
AGENTS_POST_UPDATE: 'custom_mattermost-ai_postupdate',
108111
};
109112

110113
export default WebsocketEvents;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
2+
// See LICENSE.txt for license information.
3+
4+
import NetworkManager from '@managers/network_manager';
5+
import {getFullErrorMessage} from '@utils/errors';
6+
import {logError} from '@utils/log';
7+
8+
import {
9+
stopGeneration,
10+
regenerateResponse,
11+
} from './generation_controls';
12+
13+
jest.mock('@managers/network_manager');
14+
jest.mock('@utils/errors');
15+
jest.mock('@utils/log');
16+
17+
const serverUrl = 'https://test.mattermost.com';
18+
const postId = 'test-post-id';
19+
20+
const mockClient = {
21+
stopGeneration: jest.fn(),
22+
regenerateResponse: jest.fn(),
23+
};
24+
25+
beforeAll(() => {
26+
jest.mocked(NetworkManager.getClient).mockReturnValue(mockClient as any);
27+
});
28+
29+
beforeEach(() => {
30+
jest.clearAllMocks();
31+
});
32+
33+
describe('stopGeneration', () => {
34+
it('should call client.stopGeneration and return empty object on success', async () => {
35+
mockClient.stopGeneration.mockResolvedValue(undefined);
36+
37+
const result = await stopGeneration(serverUrl, postId);
38+
39+
expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl);
40+
expect(mockClient.stopGeneration).toHaveBeenCalledWith(postId);
41+
expect(result).toEqual({});
42+
expect(result.error).toBeUndefined();
43+
});
44+
45+
it('should return error object and log error on failure', async () => {
46+
const error = new Error('Network error');
47+
const errorMessage = 'Network error occurred';
48+
mockClient.stopGeneration.mockRejectedValue(error);
49+
jest.mocked(getFullErrorMessage).mockReturnValue(errorMessage);
50+
51+
const result = await stopGeneration(serverUrl, postId);
52+
53+
expect(logError).toHaveBeenCalledWith('[stopGeneration]', error);
54+
expect(getFullErrorMessage).toHaveBeenCalledWith(error);
55+
expect(result).toEqual({error: errorMessage});
56+
});
57+
});
58+
59+
describe('regenerateResponse', () => {
60+
it('should call client.regenerateResponse and return empty object on success', async () => {
61+
mockClient.regenerateResponse.mockResolvedValue(undefined);
62+
63+
const result = await regenerateResponse(serverUrl, postId);
64+
65+
expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl);
66+
expect(mockClient.regenerateResponse).toHaveBeenCalledWith(postId);
67+
expect(result).toEqual({});
68+
expect(result.error).toBeUndefined();
69+
});
70+
71+
it('should return error object and log error on failure', async () => {
72+
const error = new Error('Network error');
73+
const errorMessage = 'Network error occurred';
74+
mockClient.regenerateResponse.mockRejectedValue(error);
75+
jest.mocked(getFullErrorMessage).mockReturnValue(errorMessage);
76+
77+
const result = await regenerateResponse(serverUrl, postId);
78+
79+
expect(logError).toHaveBeenCalledWith('[regenerateResponse]', error);
80+
expect(getFullErrorMessage).toHaveBeenCalledWith(error);
81+
expect(result).toEqual({error: errorMessage});
82+
});
83+
});

0 commit comments

Comments
 (0)