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
This commit is contained in:
Christopher Speller 2025-12-09 03:56:12 -08:00 committed by GitHub
parent f243f1cd28
commit 0c675ec2db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 3389 additions and 2 deletions

3
.gitignore vendored
View file

@ -123,3 +123,6 @@ libraries/**/**/.build
# Android sounds
android/app/src/main/res/raw/*
.aider*
# Claude Code
.claude/settings.local.json

366
CLAUDE.md Normal file
View file

@ -0,0 +1,366 @@
# CLAUDE.md
## Project Overview
React Native 0.76.9 with **New Architecture disabled** (RCT_NEW_ARCH_ENABLED=0).
## Important Development Notes
- Always run the checks before committing code
- Always test any changes using the mobile mcp before calling something complete or committing code
- Never commit any planning markdown files
## Development Workflow
### Implementing Multi-Phase Features
When implementing complex features with multiple phases:
1. **Use subagents for each phase**: Launch a subagent (Task tool with `subagent_type: general-purpose`) to implement each phase independently
2. **Test after each phase**: Use a subagent to test the feature with mobile MCP after implementation
3. **Commit after each phase**: Create a commit for each completed and tested phase
4. **Never commit planning files**: Keep `.md` planning files (like `PLAN.md`) out of commits
### Testing with Mobile MCP
When testing features that require mobile device interaction:
- **Use subagents**: Launch a subagent to handle mobile MCP testing to avoid excessive context usage
- The subagent can launch the app, navigate, take screenshots, and verify functionality
- **Don't just verify the app launches** - actually trigger and interact with the feature being tested (e.g., send messages, click buttons, verify state changes)
## Key Commands
```bash
# Setup
npm run pod-install # iOS CocoaPods (or pod-install-m1 for M1 Macs)
npm run ios-gems # Ruby gems for iOS (or ios-gems-m1 for M1 Macs)
# Development
npm start # Start Metro bundler
npm run ios # Run iOS in simulator
npm run android # Run Android in emulator
# TypeScript type checking
npm run tsc
# Fix linting issues
npm run fix
# E2E tests are in separate detox/ package
npm run e2e:ios # cd detox && npm run e2e:ios-test
npm run e2e:android # cd detox && npm run e2e:android-build && npm run e2e:android-test
# Builds delegate to Fastlane via shell scripts
npm run build:ios # ./scripts/build.sh ipa
npm run build:android # ./scripts/build.sh apk
# Utilities
npm run clean # Clean build artifacts
```
## Architecture
### Dual Database System (WatermelonDB)
**Critical**: Two separate SQLite databases via WatermelonDB:
1. **App Database**: One global database for app state, server list (`app/database/models/app/`)
2. **Server Databases**: One database per connected server for channels, users, posts (`app/database/models/server/`)
**DatabaseManager** singleton (`app/database/manager/index.ts`) manages all instances. Data operations go through operators with transformers/handlers/comparators.
**Database directory**:
- iOS: App Group directory (shared with extensions)
- Android: `${documentDirectory}/databases/`
### Network Layer
- **NetworkManager** (`app/managers/network_manager.ts`) creates **one Client instance per server**
- Uses `@mattermost/react-native-network-client`
- **Product-specific API calls** should live in `client/rest.ts` within the product folder using the **ClientMix pattern** (see `app/products/calls/client/rest.ts` or `app/products/agents/client/rest.ts` for examples). Don't call `client.doFetch()` directly from action files.
### Actions Pattern
- **Local Actions** (`app/actions/local/`): Database-only operations
- **Remote Actions** (`app/actions/remote/`): Fetch from API → use operators to persist → return `{error}` on failure
### Query Layer
**Query layer** (`app/queries/`) provides both:
- `query*`: Returns WatermelonDB Query
- `observe*`: Returns RxJS Observable
- `get*`: Returns Promise (async)
- `prepare*`: Returns prepared records for batch operations
### State Management
**Hybrid approach**:
- **WatermelonDB**: Primary data store (persisted)
- **Ephemeral Stores** (`app/store/`): In-memory transient UI state
### Navigation
- Uses **`react-native-navigation`** v7 (not react-navigation)
- Each screen is a separate registered component
- Every independently shown/hidden component must be registered as a screen for proper lifecycle management
### Products Architecture
Modular features in `app/products/` with their own database models:
- `agents/`: AI agents
- `calls/`: Voice/video calling
- `playbooks/`: Playbooks with dedicated DB models
#### Agents Streaming Architecture
**WebSocket Events:**
- Agents use `custom_mattermost-ai_postupdate` WebSocket events for streaming (start, message updates, reasoning, tool calls, annotations, end)
- NO specific WebSocket event for tool call status changes after approval/rejection
- Updates arrive via standard `POST_EDITED` event after backend executes tools
**Critical Pattern:**
- Components must clear local streaming state on `ENDED` event to switch from ephemeral streaming data to persisted database data
- Otherwise stale streaming state prevents POST_EDITED updates from displaying
### Custom Native Modules
Located at `libraries/@mattermost/`:
- `@mattermost/rnutils` - Native utilities (orientation, notifications)
- `@mattermost/rnshare` - Share extension integration
- `@mattermost/hardware-keyboard` - External keyboard detection
- `@mattermost/secure-pdf-viewer` - Secure PDF viewing
**Native Bridge patterns** (when modifying native modules):
- iOS: `RCT_EXPORT_MODULE()`, `RCT_EXPORT_METHOD()` in Objective-C++
- Android: Extend `ReactContextBaseJavaModule`, use `@ReactMethod`
### TypeScript Path Aliases
```typescript
@actions/* → app/actions/*
@agents/* → app/products/agents/*
@calls/* → app/products/calls/*
@database/* → app/database/*
@queries/* → app/queries/*
@store → app/store/index
@share/* → share_extension/*
@typings/* → types/*
// ... (see tsconfig.json for complete list)
```
### Share Extension
**Separate bundle** at `share_extension/` for Android system share. Shares code with main app but runs independently.
* iOS has its own native implementation for the Share Extension with Swift and SwiftUI
**Important:** Share extension on Android uses **React Navigation** (not react-native-navigation like main app).
## Testing
### Test Organization
- **Jest coverage excludes** `/components/` and `/screens/` directories
- Mock database manager at `app/database/manager/__mocks__/index.ts`
- **E2E tests** in separate `detox/` package (not in main package.json)
- Add mocks to central `setup.ts` file, not individual test files
### Testing Patterns
#### Database Testing
- Tests use **real in-memory databases** (LokiJS), not mocks
- **Critical**: Set `extraLokiOptions: {autosave: false}` to prevent memory leaks from lingering timeouts
- Always clean up: `await DatabaseManager.destroyServerDatabase(serverUrl)` in `afterEach`
- Initialize database in `beforeEach`: `await DatabaseManager.init([serverUrl])`
#### Ephemeral Store Testing
- Use `jest.resetModules()` between tests to clear singleton state
- Require fresh imports after reset: `const Store = require('./store').default;`
- Pattern from `app/store/ephemeral_store.test.ts`
#### WebSocket Testing
- Mock connection objects, not the WebSocket class itself
- Create mock with methods: `onOpen`, `onClose`, `onError`, `onMessage`, `send`, `readyState`
- Use `client.open()` in tests to trigger `onOpen` callback
- Pattern from `app/client/websocket/index.test.ts`
#### Timer Testing
- **Always use**: `jest.useFakeTimers({doNotFake: ['nextTick']})` to let promises resolve
- Helper: `advanceTimers(ms)` from `test/timer_helpers.ts` advances time AND waits for promises
- Never fake `nextTick` or async operations will hang
#### Component Rendering
- Three helpers in `test/intl-test-helper.tsx`:
- `renderWithIntl`: Basic internationalization
- `renderWithIntlAndTheme`: + Theme context
- `renderWithEverything`: + Database + Server URL
- Use `renderWithEverything` when components need database access
- Wrap async state updates in `act()` when testing React components
#### Remote Action Testing
- Mock `NetworkManager.getClient` in `beforeAll` to return mock client
- Mock client should implement all methods used in tests
- Test pattern: Setup DB state → Call action → Assert no error and data returned
- Pattern from `app/actions/remote/channel.test.ts`
#### DeviceEventEmitter Testing
- Use `DeviceEventEmitter.addListener()` to spy on events
- Remove listener in cleanup: `listener.remove()`
- Pattern: Store listener callback in jest.fn(), assert it was called with expected data
### Test Utilities
**TestHelper** singleton at `test/test_helper.ts`:
- `generateId()`: Deterministic IDs for tests
- `setupServerDatabase()`: Complete DB setup with basic entities
- `fakeUser()`, `fakeChannel()`, etc.: Entity generators with sensible defaults
- `fakeUserModel()`, `fakeChannelModel()`: WatermelonDB model creators
### Writing Quality Tests
**Test names:**
- Use `it('should...')` format, not `test('happy path')`
- Check array lengths in addition to individual items to ensure no extra elements
- Test expectations must match actual implementation behavior - if code deletes state immediately, test for `undefined`, not for modified state values
**Mocking:**
- Use `jest.mocked()` for full implementations
- Use `(thing as jest.Mock)` type assertion when mocking partial implementations that don't satisfy the full interface (e.g., `NetworkManager.getClient` returning minimal mock client)
- Avoid `any` types in tests - use `typeof import('./module').default` for proper typing of dynamically imported modules
**Don't test:**
- JavaScript operators (`===`, `JSON.stringify()`) or library behavior (React, RxJS)
- "Mocks calling mocks" - thin wrapper functions that just pass data through
- Multiple input variations when behavior is identical (testing 0, 1, 5 items when function doesn't special-case counts)
- Duplicate assertions - if test A verifies X, don't add test B that only verifies X
- AI-generated tests are heavily scrutinized and often rejected if they don't demonstrate real intended behavior
**Do test:**
- Business logic, error handling, integration points, side effects
- One example per code path is sufficient
## Development Practices
### TypeScript
- Trust well-formed data - don't add defensive null checks if types guarantee existence
- Use `??` (nullish coalescing) instead of `||` for fallbacks when appropriate
- Avoid non-null assertions (`!`) - use optional chaining: `metadataRes.metadata?.followers?.length ?? 0`
- Type function parameters using `ComponentProps<typeof Component>` instead of `as const` workarounds
- JSDoc comments must match actual parameter names and types
- **Prefer const objects over enums** for better tree-shaking: `export const Status = { Pending: 0, Done: 1 } as const;` with companion type `export type Status = typeof Status[keyof typeof Status];`
### React Hooks
- **Always explain why dependencies are omitted** from exhaustive-deps with specific comments
- Don't over-optimize with `useMemo` - only use for expensive calculations or objects passed as props to children
- Use `useCallback` for render functions passed to components, not for functions called within render
- Move module-level constants outside components
- Replace state variables with derived values when possible
- Stable references (refs, dispatch functions) don't need to be in dependency arrays but must have eslint-disable comments
- React-Native-Reanimated shared values (`.value`) don't cause re-renders and don't need to be dependencies
- **Critical**: When modifying existing `useEffect` hooks, you MUST include ALL dependencies used inside the effect, even if the original code had an incomplete dependency array - ESLint's `react-hooks/exhaustive-deps` rule is enforced strictly
- **Memoize callbacks for list items**: When rendering lists with `.map()`, avoid inline arrow functions like `onPress={() => handler(item.id)}`. Instead, have the child component accept the ID and call the callback internally, allowing the parent to pass a single memoized callback reference.
### React Native & UI
- Don't create hooks inside render functions - extract as local components
- Prefer `Button` components over `TouchableOpacity` when appropriate
- Non-memoized inline styles add render stress - define in stylesheet instead
- **`StyleSheet.create` is unnecessary** when using `makeStyleSheetFromTheme` - just return the plain object
- **Place `getStyleSheet` at file top** (after imports, before interfaces/components) not at the bottom
- Use `Platform.select()` for platform-specific values instead of ternaries
- StyleProps support nested lists - no need for custom `concatStyles()`
- Use `withTiming()` consistently for both states in animations
- Test components with long strings to ensure proper text handling
- Use constants from `PREFERENCES.THEMES` instead of hardcoded colors
- **Use `react-native-reanimated`** instead of React Native's `Animated` API for all animations (better performance, runs on UI thread)
- **Use `usePreventDoubleTap` hook** for button press handlers to prevent accidental double submissions
- **Use `useServerUrl()` hook** instead of passing `serverUrl` as a prop - it's available via context
- **Use existing components**: Check for `<Loading>` instead of `<ActivityIndicator>`, `safeParseJSON()` instead of try/catch JSON.parse
- **Parent checks before mounting**: If a child component would return null for empty data, have the parent conditionally render instead (e.g., `{items.length > 0 && <ItemList items={items} />}`)
- **Use `@utils/url` utilities**: `tryOpenURL()` instead of `Linking.openURL()`, `getUrlDomain()` with `urlParse` instead of `new URL()`
### Code Quality & Linting
**Import Organization:**
- Consolidate all imports from the same module into a single statement
- Use inline `type` keyword for type imports: `import {SomeValue, type SomeType} from '@module'`
- Example: `import {StreamingEvents, type StreamingState} from '@agents/types'`
**Unused Code:**
- Remove unused imports, parameters, interfaces, and variables completely
- ESLint enforces strict no-unused-vars - code with unused declarations will fail CI
- When a parameter is truly unused, remove it from the function signature
**Pre-commit:**
- Run `npm run fix` to auto-fix linting issues before committing
- Pre-commit hook runs ESLint + TypeScript checking automatically
### Error Handling & Logging
- Use `logError()` instead of `console.error()` or `console.log()`
- Use `logDebug()` for debug-level information
- Don't ignore potential errors silently - handle them or add intentional comments
- Don't log sensitive information
- **Add function/class prefix to logs**: e.g., `logError('[ClassName.methodName]', error)` to make debugging easier
### State Management
- Always handle errors from database operations
- Consider race conditions in async functions within effects
- Local state initialized with prop values won't update when props change - sync with `useEffect` if needed
### Performance
- Create parsers/expensive objects only once, not on every render (use refs or useMemo)
- Memoize AST output to avoid regenerating on every render
- Use named constants instead of magic numbers and check if one already exists.
### Localization (i18n)
- **CRITICAL**: Only update `en.json` - never modify other language files or Weblate gets corrupted
- Default messages in code must match JSON translations exactly, including newlines
- Translation IDs should be descriptive enough for translators to understand context
- Translate user-facing strings, not debug/error messages
### Markdown Component Usage
**Required props:**
- `baseTextStyle`, `value`, `theme`, `location`
- Use `Screens.CHANNEL` or appropriate constant from `@constants` for the `location` prop
- `textStyles`, `blockStyles`, `enableLatex`, and similar props are auto-generated via HOC
**Important Limitations:**
- **Cannot embed custom React components inline**: The Markdown component renders CommonMark to native components. You cannot insert custom React components (like badges, icons) inline within the markdown text flow
- **Link handling**: Links in markdown go through `openLink()` → deep link system → `tryOpenURL()`. Custom URL schemes (e.g., `citation:`, `action:`) will show error alerts unless you implement a full deep link handler in `app/utils/deep_link/`
- **Post-processing not possible**: Unlike web, you cannot post-process the rendered markdown JSX tree to replace markers with React components
### Adding New Post Types
When adding custom post types (e.g., for new products):
1. Add the type string to `PostType` union in `types/api/posts.d.ts`
2. Define constants in your product's constants file (e.g., `app/products/agents/constants.ts`)
3. Use the globally-available `Post` type (defined in `types/api/posts.d.ts`) - no import needed
### Platform-Specific
- iOS: Opt out of iOS 18+ features (liquid glass) until UI is properly addressed
- Android: Test on multiple API levels (34, 35, 36) to verify behavior with edge-to-edge changes
- Use `Set` instead of `Array` for exception lists (faster, ensures uniqueness)
### testID Convention
Components use hierarchical testIDs: `component.subcomponent.element`
- Example: `channel_list.category.CATEGORY.channel_item.ID.display_name`
- Example: `channel.post_draft.send_action.send.button`
## Common Mistakes to Avoid
### JavaScript Compatibility
- **Don't use `Object.hasOwn()`** - React Native doesn't support ES2022+ features
- ❌ `Object.hasOwn(obj, 'key')`
- ✅ `'key' in obj`
### Anti-Patterns
- Conditionals in tests for TypeScript satisfaction
- Creating new arrays/objects on every render when passed as props
- Over-engineering - follow YAGNI principle
- Impossible states with boolean + optional parameters that depend on each other
- Redundant checks - if `array.length >= 3`, indices 0,1,2 are guaranteed to exist
- Verbose error messages that reveal internal system details
- Mass assignment risks - whitelist fields instead of sending `Partial<Model>` objects
- Duplicate conditional branches that return identical results - consolidate into a single condition (e.g., `if (A && B)` and `if (A && !B)` both returning same thing should be just `if (A)`)
### Security
- Question whether showing secrets with "eye" toggle is a security concern
- Don't commit files that likely contain secrets (.env, credentials.json)
- Consider pre-populating sensitive fields with `*********` instead of actual values
## Configuration
- **Patches**: Applied via `patch-package` on postinstall (`patches/` directory)
- **Self-compiled apps**: Require your own Mattermost Push Notification Service
- **Self-signed certificates**: Not supported
## Development Notes
### Hot Reload & Build Times
- **Hot reload**: ~3 seconds for JS/TS changes; does NOT work for native code changes
- **Native rebuilds**: 10-30 minutes (avoid unless necessary)
- **Pre-commit hook**: Runs ESLint + incremental TypeScript checking
### Known Issues
- Many components require `theme` prop - check for `useTheme()` hook in parent component

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {handleAgentPostUpdate} from '@agents/actions/websocket';
import * as bookmark from '@actions/local/channel_bookmark';
import * as scheduledPost from '@actions/websocket/scheduled_post';
import * as calls from '@calls/connection/websocket_event_handlers';
@ -301,6 +303,11 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
case WebsocketEvents.CUSTOM_PROFILE_ATTRIBUTES_FIELD_DELETED:
handleCustomProfileAttributesFieldDeletedEvent(serverUrl, msg);
break;
// Agents
case WebsocketEvents.AGENTS_POST_UPDATE:
handleAgentPostUpdate(msg);
break;
}
handlePlaybookEvents(serverUrl, msg);
}

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import streamingStore from '@agents/store/streaming_store';
import {isAgentPost} from '@agents/utils';
import {DeviceEventEmitter} from 'react-native';
import {storeMyChannelsForTeam, markChannelAsUnread, markChannelAsViewed, updateLastPostAt} from '@actions/local/channel';
@ -267,7 +269,11 @@ export async function handlePostEdited(serverUrl: string, msg: WebSocketMessage)
});
models.push(...postModels);
operator.batchRecords(models, 'handlePostEdited');
await operator.batchRecords(models, 'handlePostEdited');
if (isAgentPost(post)) {
streamingStore.removePost(post.id);
}
}
export async function handlePostDeleted(serverUrl: string, msg: WebSocketMessage) {

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import ClientAgents, {type ClientAgentsMix} from '@agents/client/rest';
import ClientCalls, {type ClientCallsMix} from '@calls/client/rest';
import ClientPlugins, {type ClientPluginsMix} from '@client/rest/plugins';
import ClientPlaybooks, {type ClientPlaybooksMix} from '@playbooks/client/rest';
@ -30,6 +32,7 @@ import ClientUsers, {type ClientUsersMix} from './users';
import type {APIClientInterface} from '@mattermost/react-native-network-client';
interface Client extends ClientBase,
ClientAgentsMix,
ClientAppsMix,
ClientCategoriesMix,
ClientChannelsMix,
@ -57,6 +60,7 @@ interface Client extends ClientBase,
}
class Client extends mix(ClientBase).with(
ClientAgents,
ClientApps,
ClientCategories,
ClientChannels,

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import AgentPost from '@agents/components/agent_post';
import {isAgentPost} from '@agents/utils';
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, type StyleProp, View, type ViewStyle, TouchableHighlight} from 'react-native';
@ -157,6 +159,7 @@ const Post = ({
const isFailed = isPostFailed(post);
const isSystemPost = isSystemMessage(post);
const isCallsPost = isCallsCustomMessage(post);
const isAgentPostType = isAgentPost(post);
const hasBeenDeleted = (post.deleteAt !== 0);
const isWebHook = isFromWebhook(post);
const hasSameRoot = useMemo(() => {
@ -251,6 +254,8 @@ const Post = ({
clearTimeout(t);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- Timer only needs to reset when post.id changes, not on other prop updates
}, [post.id]);
useEffect(() => {
@ -264,6 +269,8 @@ const Post = ({
PerformanceMetricsManager.finishLoad(location === 'Thread' ? 'THREAD' : 'CHANNEL', serverUrl);
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl);
// eslint-disable-next-line react-hooks/exhaustive-deps -- Performance metrics should only run once on mount
}, []);
const highlightSaved = isSaved && !skipSavedHeader;
@ -356,6 +363,14 @@ const Post = ({
joiningChannelId={null}
/>
);
} else if (isAgentPostType && !hasBeenDeleted) {
body = (
<AgentPost
post={post}
currentUserId={currentUser?.id}
location={location}
/>
);
} else {
body = (
<Body

View file

@ -35,6 +35,8 @@ export const PostTypes = {
SYSTEM_AUTO_RESPONDER: 'system_auto_responder',
CUSTOM_CALLS: 'custom_calls',
CUSTOM_CALLS_RECORDING: 'custom_calls_recording',
CUSTOM_LLMBOT: 'custom_llmbot',
CUSTOM_LLM_POSTBACK: 'custom_llm_postback',
} as const;
export const PostPriorityColors = {

View file

@ -7,6 +7,9 @@ import keyMirror from '@utils/key_mirror';
export const SNACK_BAR_TYPE = keyMirror({
ADD_CHANNEL_MEMBERS: null,
AGENT_STOP_ERROR: null,
AGENT_REGENERATE_ERROR: null,
AGENT_TOOL_APPROVAL_ERROR: null,
CODE_COPIED: null,
FAVORITE_CHANNEL: null,
FOLLOW_THREAD: null,
@ -46,6 +49,18 @@ const messages = defineMessages({
id: 'snack.bar.channel.members.added',
defaultMessage: '{numMembers, number} {numMembers, plural, one {member} other {members}} added',
},
AGENT_STOP_ERROR: {
id: 'snack.bar.agent.stop.error',
defaultMessage: 'Failed to stop generation',
},
AGENT_REGENERATE_ERROR: {
id: 'snack.bar.agent.regenerate.error',
defaultMessage: 'Failed to regenerate response',
},
AGENT_TOOL_APPROVAL_ERROR: {
id: 'snack.bar.agent.tool.approval.error',
defaultMessage: 'Failed to submit tool approval',
},
CODE_COPIED: {
id: 'snack.bar.code.copied',
defaultMessage: 'Code copied to clipboard',
@ -110,6 +125,24 @@ export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
iconName: 'check',
canUndo: false,
},
AGENT_STOP_ERROR: {
message: messages.AGENT_STOP_ERROR,
iconName: 'alert-outline',
canUndo: false,
type: MESSAGE_TYPE.ERROR,
},
AGENT_REGENERATE_ERROR: {
message: messages.AGENT_REGENERATE_ERROR,
iconName: 'alert-outline',
canUndo: false,
type: MESSAGE_TYPE.ERROR,
},
AGENT_TOOL_APPROVAL_ERROR: {
message: messages.AGENT_TOOL_APPROVAL_ERROR,
iconName: 'alert-outline',
canUndo: false,
type: MESSAGE_TYPE.ERROR,
},
CODE_COPIED: {
message: messages.CODE_COPIED,
iconName: 'content-copy',

View file

@ -105,6 +105,9 @@ const WebsocketEvents = {
CUSTOM_PROFILE_ATTRIBUTES_FIELD_UPDATED: 'custom_profile_attributes_field_updated',
CUSTOM_PROFILE_ATTRIBUTES_FIELD_CREATED: 'custom_profile_attributes_field_created',
CUSTOM_PROFILE_ATTRIBUTES_FIELD_DELETED: 'custom_profile_attributes_field_deleted',
// Agents
AGENTS_POST_UPDATE: 'custom_mattermost-ai_postupdate',
};
export default WebsocketEvents;

View file

@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import NetworkManager from '@managers/network_manager';
import {getFullErrorMessage} from '@utils/errors';
import {logError} from '@utils/log';
import {
stopGeneration,
regenerateResponse,
} from './generation_controls';
jest.mock('@managers/network_manager');
jest.mock('@utils/errors');
jest.mock('@utils/log');
const serverUrl = 'https://test.mattermost.com';
const postId = 'test-post-id';
const mockClient = {
stopGeneration: jest.fn(),
regenerateResponse: jest.fn(),
};
beforeAll(() => {
jest.mocked(NetworkManager.getClient).mockReturnValue(mockClient as any);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('stopGeneration', () => {
it('should call client.stopGeneration and return empty object on success', async () => {
mockClient.stopGeneration.mockResolvedValue(undefined);
const result = await stopGeneration(serverUrl, postId);
expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl);
expect(mockClient.stopGeneration).toHaveBeenCalledWith(postId);
expect(result).toEqual({});
expect(result.error).toBeUndefined();
});
it('should return error object and log error on failure', async () => {
const error = new Error('Network error');
const errorMessage = 'Network error occurred';
mockClient.stopGeneration.mockRejectedValue(error);
jest.mocked(getFullErrorMessage).mockReturnValue(errorMessage);
const result = await stopGeneration(serverUrl, postId);
expect(logError).toHaveBeenCalledWith('[stopGeneration]', error);
expect(getFullErrorMessage).toHaveBeenCalledWith(error);
expect(result).toEqual({error: errorMessage});
});
});
describe('regenerateResponse', () => {
it('should call client.regenerateResponse and return empty object on success', async () => {
mockClient.regenerateResponse.mockResolvedValue(undefined);
const result = await regenerateResponse(serverUrl, postId);
expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl);
expect(mockClient.regenerateResponse).toHaveBeenCalledWith(postId);
expect(result).toEqual({});
expect(result.error).toBeUndefined();
});
it('should return error object and log error on failure', async () => {
const error = new Error('Network error');
const errorMessage = 'Network error occurred';
mockClient.regenerateResponse.mockRejectedValue(error);
jest.mocked(getFullErrorMessage).mockReturnValue(errorMessage);
const result = await regenerateResponse(serverUrl, postId);
expect(logError).toHaveBeenCalledWith('[regenerateResponse]', error);
expect(getFullErrorMessage).toHaveBeenCalledWith(error);
expect(result).toEqual({error: errorMessage});
});
});

View file

@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import NetworkManager from '@managers/network_manager';
import {getFullErrorMessage} from '@utils/errors';
import {logError} from '@utils/log';
/**
* Stop the current generation for a post
* @param serverUrl The server URL
* @param postId The post ID to stop generation for
* @returns {error} on failure
*/
export async function stopGeneration(
serverUrl: string,
postId: string,
): Promise<{error?: unknown}> {
try {
const client = NetworkManager.getClient(serverUrl);
await client.stopGeneration(postId);
return {};
} catch (error) {
logError('[stopGeneration]', error);
return {error: getFullErrorMessage(error)};
}
}
/**
* Regenerate a response for a post
* @param serverUrl The server URL
* @param postId The post ID to regenerate response for
* @returns {error} on failure
*/
export async function regenerateResponse(
serverUrl: string,
postId: string,
): Promise<{error?: unknown}> {
try {
const client = NetworkManager.getClient(serverUrl);
await client.regenerateResponse(postId);
return {};
} catch (error) {
logError('[regenerateResponse]', error);
return {error: getFullErrorMessage(error)};
}
}

View file

@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import NetworkManager from '@managers/network_manager';
import {getFullErrorMessage} from '@utils/errors';
import {logError} from '@utils/log';
import {submitToolApproval} from './tool_approval';
jest.mock('@managers/network_manager');
jest.mock('@utils/errors');
jest.mock('@utils/log');
const serverUrl = 'https://test.mattermost.com';
const postId = 'post123';
const acceptedToolIds = ['tool1', 'tool2', 'tool3'];
const mockClient = {
submitToolApproval: jest.fn(),
};
beforeAll(() => {
jest.mocked(NetworkManager.getClient).mockReturnValue(mockClient as any);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('submitToolApproval', () => {
it('should call client.submitToolApproval and return empty object on success', async () => {
mockClient.submitToolApproval.mockResolvedValue(undefined);
const result = await submitToolApproval(serverUrl, postId, acceptedToolIds);
expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl);
expect(mockClient.submitToolApproval).toHaveBeenCalledWith(postId, acceptedToolIds);
expect(result).toEqual({});
expect(result.error).toBeUndefined();
});
it('should return error object and log error on failure', async () => {
const error = new Error('Network error');
const errorMessage = 'Network error occurred';
mockClient.submitToolApproval.mockRejectedValue(error);
jest.mocked(getFullErrorMessage).mockReturnValue(errorMessage);
const result = await submitToolApproval(serverUrl, postId, acceptedToolIds);
expect(logError).toHaveBeenCalledWith('[submitToolApproval]', error);
expect(getFullErrorMessage).toHaveBeenCalledWith(error);
expect(result).toEqual({error: errorMessage});
});
});

View file

@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import NetworkManager from '@managers/network_manager';
import {getFullErrorMessage} from '@utils/errors';
import {logError} from '@utils/log';
/**
* Submit tool approval decisions to the server
* @param serverUrl The server URL
* @param postId The post ID containing the tool calls
* @param acceptedToolIds Array of tool IDs that were approved
* @returns {error} on failure
*/
export async function submitToolApproval(
serverUrl: string,
postId: string,
acceptedToolIds: string[],
): Promise<{error?: unknown}> {
try {
const client = NetworkManager.getClient(serverUrl);
await client.submitToolApproval(postId, acceptedToolIds);
return {};
} catch (error) {
logError('[submitToolApproval]', error);
return {error: getFullErrorMessage(error)};
}
}

View file

@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import streamingStore from '@agents/store/streaming_store';
import {handleAgentPostUpdate} from './index';
import type {PostUpdateWebsocketMessage} from '@agents/types';
// Mock the streaming store
jest.mock('@agents/store/streaming_store', () => ({
__esModule: true,
default: {
handleWebSocketMessage: jest.fn(),
},
}));
describe('handleAgentPostUpdate', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call streamingStore.handleWebSocketMessage with message data', () => {
const messageData: PostUpdateWebsocketMessage = {
post_id: 'post123',
next: 'Hello world',
control: 'start',
};
const msg: WebSocketMessage<PostUpdateWebsocketMessage> = {
event: 'custom_mattermost-ai_postupdate',
data: messageData,
broadcast: {
omit_users: {},
user_id: 'user123',
channel_id: 'channel123',
team_id: 'team123',
},
seq: 1,
};
handleAgentPostUpdate(msg);
expect(streamingStore.handleWebSocketMessage).toHaveBeenCalledTimes(1);
expect(streamingStore.handleWebSocketMessage).toHaveBeenCalledWith(messageData);
});
it('should return early when data is undefined', () => {
const msg = {
event: 'custom_mattermost-ai_postupdate',
data: undefined,
broadcast: {
omit_users: {},
user_id: 'user123',
channel_id: 'channel123',
team_id: 'team123',
},
seq: 1,
};
handleAgentPostUpdate(msg as unknown as WebSocketMessage<PostUpdateWebsocketMessage>);
expect(streamingStore.handleWebSocketMessage).not.toHaveBeenCalled();
});
it('should return early when data is null', () => {
const msg = {
event: 'custom_mattermost-ai_postupdate',
data: null,
broadcast: {
omit_users: {},
user_id: 'user123',
channel_id: 'channel123',
team_id: 'team123',
},
seq: 2,
};
handleAgentPostUpdate(msg as unknown as WebSocketMessage<PostUpdateWebsocketMessage>);
expect(streamingStore.handleWebSocketMessage).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import streamingStore from '@agents/store/streaming_store';
import type {PostUpdateWebsocketMessage} from '@agents/types';
/**
* Handle agent post update WebSocket events
* Called when the server sends streaming updates for agent responses
*/
export function handleAgentPostUpdate(msg: WebSocketMessage<PostUpdateWebsocketMessage>): void {
if (!msg.data) {
return;
}
// Delegate to the streaming store
streamingStore.handleWebSocketMessage(msg.data);
}

View file

@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export interface ClientAgentsMix {
getAgentsRoute: () => string;
stopGeneration: (postId: string) => Promise<void>;
regenerateResponse: (postId: string) => Promise<void>;
submitToolApproval: (postId: string, acceptedToolIds: string[]) => Promise<void>;
}
const ClientAgents = (superclass: any) => class extends superclass {
getAgentsRoute = () => {
return '/plugins/mattermost-ai';
};
stopGeneration = async (postId: string) => {
return this.doFetch(
`${this.getAgentsRoute()}/post/${postId}/stop`,
{method: 'post'},
);
};
regenerateResponse = async (postId: string) => {
return this.doFetch(
`${this.getAgentsRoute()}/post/${postId}/regenerate`,
{method: 'post'},
);
};
submitToolApproval = async (postId: string, acceptedToolIds: string[]) => {
return this.doFetch(
`${this.getAgentsRoute()}/post/${postId}/tool_call`,
{
method: 'post',
body: {accepted_tool_ids: acceptedToolIds},
},
);
};
};
export default ClientAgents;

View file

@ -0,0 +1,203 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {regenerateResponse, stopGeneration} from '@agents/actions/remote/generation_controls';
import {useStreamingState} from '@agents/store/streaming_store';
import {type Annotation, type ToolCall} from '@agents/types';
import {isPostRequester} from '@agents/utils';
import React, {useCallback, useMemo} from 'react';
import {View} from 'react-native';
import FormattedText from '@components/formatted_text';
import Markdown from '@components/markdown';
import {SNACK_BAR_TYPE} from '@constants/snack_bar';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {safeParseJSON} from '@utils/helpers';
import {showSnackBar} from '@utils/snack_bar';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import CitationsList from '../citations_list';
import ControlsBar from '../controls_bar';
import ReasoningDisplay from '../reasoning_display';
import ToolApprovalSet from '../tool_approval_set';
import StreamingIndicator from './streaming_indicator';
import type PostModel from '@typings/database/models/servers/post';
import type {AvailableScreens} from '@typings/screens/navigation';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
},
messageContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'flex-start',
},
messageText: {
color: theme.centerChannelColor,
fontSize: 15,
lineHeight: 20,
},
precontentContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
},
precontentText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
fontSize: 14,
fontStyle: 'italic',
marginRight: 8,
},
};
});
interface AgentPostProps {
post: PostModel;
currentUserId?: string;
location: AvailableScreens;
}
/**
* Custom post component for agent responses
* Handles streaming text updates and displays animated cursor during generation
*/
const AgentPost = ({post, currentUserId, location}: AgentPostProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
// Extract persisted reasoning from post props
const persistedReasoning = useMemo(() => {
const props = post.props as Record<string, unknown>;
return (props?.reasoning_summary as string) || '';
}, [post.props]);
// Extract persisted tool calls from post props
const persistedToolCalls = useMemo((): ToolCall[] => {
const props = post.props as Record<string, unknown>;
const toolCallsJson = props?.pending_tool_call as string;
if (toolCallsJson) {
const parsed = safeParseJSON(toolCallsJson);
return Array.isArray(parsed) ? parsed as ToolCall[] : [];
}
return [];
}, [post.props]);
// Extract persisted annotations from post props
const persistedAnnotations = useMemo((): Annotation[] => {
const props = post.props as Record<string, unknown>;
const annotationsJson = props?.annotations as string;
if (annotationsJson) {
const parsed = safeParseJSON(annotationsJson);
return Array.isArray(parsed) ? parsed as Annotation[] : [];
}
return [];
}, [post.props]);
// Subscribe to streaming state via observable
const streamingState = useStreamingState(post.id);
// Determine the message to display (use ?? not || to preserve empty string during streaming)
const displayMessage = streamingState?.message ?? post.message ?? '';
const isGenerating = streamingState?.generating ?? false;
const isPrecontent = streamingState?.precontent ?? false;
// Determine reasoning state - use streaming state if available, otherwise use persisted
const reasoningSummary = streamingState?.reasoning ?? persistedReasoning;
const isReasoningLoading = streamingState?.isReasoningLoading ?? false;
const showReasoning = streamingState?.showReasoning ?? (persistedReasoning !== '');
// Determine tool calls - use streaming state if available, otherwise use persisted
const toolCalls = streamingState?.toolCalls ?? persistedToolCalls;
// Determine annotations - use streaming state if available, otherwise use persisted
const annotations = streamingState?.annotations ?? persistedAnnotations;
// Check permissions
const isRequester = useMemo(() => {
return currentUserId ? isPostRequester(post, currentUserId) : false;
}, [post, currentUserId]);
// Determine if generation is in progress (generating or reasoning)
const isGenerationInProgress = isGenerating || isReasoningLoading;
// Show controls based on state and permissions
const showStopButton = isGenerationInProgress && isRequester;
const hasContent = displayMessage !== '' || reasoningSummary !== '';
const showRegenerateButton = !isGenerationInProgress && isRequester && hasContent;
// Handler for stop button
const handleStop = useCallback(async () => {
const {error} = await stopGeneration(serverUrl, post.id);
if (error) {
showSnackBar({barType: SNACK_BAR_TYPE.AGENT_STOP_ERROR});
}
}, [serverUrl, post.id]);
// Handler for regenerate button
const handleRegenerate = useCallback(async () => {
const {error} = await regenerateResponse(serverUrl, post.id);
if (error) {
showSnackBar({barType: SNACK_BAR_TYPE.AGENT_REGENERATE_ERROR});
}
}, [serverUrl, post.id]);
return (
<View style={styles.container}>
{showReasoning && (
<ReasoningDisplay
reasoningSummary={reasoningSummary}
isReasoningLoading={isReasoningLoading}
/>
)}
{isPrecontent ? (
<View style={styles.precontentContainer}>
<FormattedText
id='agents.generating'
defaultMessage='Generating response...'
style={styles.precontentText}
/>
<StreamingIndicator/>
</View>
) : (
<View style={styles.messageContainer}>
{displayMessage ? (
<Markdown
baseTextStyle={styles.messageText}
value={displayMessage}
theme={theme}
location={location}
/>
) : null}
{isGenerating && !isPrecontent && (
<StreamingIndicator/>
)}
</View>
)}
{toolCalls.length > 0 && (
<ToolApprovalSet
postId={post.id}
toolCalls={toolCalls}
/>
)}
{annotations.length > 0 && (
<CitationsList annotations={annotations}/>
)}
{(showStopButton || showRegenerateButton) && (
<ControlsBar
showStopButton={showStopButton}
showRegenerateButton={showRegenerateButton}
onStop={handleStop}
onRegenerate={handleRegenerate}
/>
)}
</View>
);
};
export default AgentPost;

View file

@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import {View} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming,
cancelAnimation,
} from 'react-native-reanimated';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 4,
minHeight: 20,
},
cursor: {
width: 2,
height: 16,
borderRadius: 1,
marginLeft: 2,
backgroundColor: theme.centerChannelColor,
},
}));
/**
* Animated indicator shown while agent is generating a response
* Shows a pulsing cursor for mobile-optimized UX
*/
const StreamingIndicator = () => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const opacity = useSharedValue(0.3);
useEffect(() => {
opacity.value = withRepeat(
withSequence(
withTiming(1, {duration: 800}),
withTiming(0.3, {duration: 800}),
),
-1,
);
return () => {
cancelAnimation(opacity);
};
}, [opacity]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
return (
<View style={styles.container}>
<Animated.View style={[styles.cursor, animatedStyle]}/>
</View>
);
};
export default StreamingIndicator;

View file

@ -0,0 +1,185 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {TOUCH_TARGET_SIZE} from '@agents/constants';
import React, {useCallback, useState} from 'react';
import {Text, TouchableOpacity, View} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {getUrlDomain, tryOpenURL} from '@utils/url';
import type {Annotation} from '@agents/types';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
marginTop: 12,
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.12),
paddingTop: 12,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 8,
minHeight: TOUCH_TARGET_SIZE,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
headerText: {
fontSize: 14,
fontWeight: 600,
color: changeOpacity(theme.centerChannelColor, 0.72),
marginLeft: 8,
},
citationsList: {
marginTop: 4,
},
citationItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 12,
marginBottom: 8,
borderRadius: 4,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
minHeight: TOUCH_TARGET_SIZE,
},
citationIcon: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: changeOpacity(theme.linkColor, 0.08),
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
citationContent: {
flex: 1,
marginRight: 8,
},
citationTitle: {
fontSize: 14,
fontWeight: 600,
color: theme.centerChannelColor,
marginBottom: 2,
},
citationUrl: {
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.64),
},
};
});
interface CitationsListProps {
annotations: Annotation[];
}
/**
* Component to display a collapsible list of citations below the agent response
*/
const CitationsList = ({annotations}: CitationsListProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const [isExpanded, setIsExpanded] = useState(false);
const animatedHeight = useSharedValue(0);
const opacity = useSharedValue(0);
const handleToggle = useCallback(() => {
const newExpanded = !isExpanded;
setIsExpanded(newExpanded);
animatedHeight.value = withTiming(newExpanded ? 1 : 0, {duration: 250});
opacity.value = withTiming(newExpanded ? 1 : 0, {duration: 250});
}, [isExpanded, animatedHeight, opacity]);
const handleCitationPress = useCallback((url: string) => {
if (url) {
tryOpenURL(url);
}
}, []);
const listAnimatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
maxHeight: animatedHeight.value === 0 ? 0 : undefined,
overflow: 'hidden',
}));
return (
<View style={styles.container}>
<TouchableOpacity
onPress={handleToggle}
style={styles.header}
testID='citations.list.toggle'
>
<View style={styles.headerLeft}>
<CompassIcon
name='link-variant'
size={16}
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
<FormattedText
id='agents.citations.title'
defaultMessage='Sources ({count})'
values={{count: annotations.length}}
style={styles.headerText}
/>
</View>
<CompassIcon
name={isExpanded ? 'chevron-up' : 'chevron-down'}
size={20}
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
</TouchableOpacity>
{isExpanded && (
<Animated.View style={[styles.citationsList, listAnimatedStyle]}>
{annotations.map((annotation, idx) => (
<TouchableOpacity
key={`citation-${annotation.index}-${idx}`}
onPress={() => handleCitationPress(annotation.url)}
style={styles.citationItem}
testID={`citations.list.item.${annotation.index}`}
>
<View style={styles.citationIcon}>
<CompassIcon
name='link-variant'
size={14}
color={theme.linkColor}
/>
</View>
<View style={styles.citationContent}>
<Text
style={styles.citationTitle}
numberOfLines={2}
>
{annotation.title || getUrlDomain(annotation.url)}
</Text>
<Text
style={styles.citationUrl}
numberOfLines={1}
>
{getUrlDomain(annotation.url)}
</Text>
</View>
<CompassIcon
name='open-in-new'
size={16}
color={changeOpacity(theme.centerChannelColor, 0.56)}
/>
</TouchableOpacity>
))}
</Animated.View>
)}
</View>
);
};
export default CitationsList;

View file

@ -0,0 +1,136 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, TouchableOpacity, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {usePreventDoubleTap} from '@hooks/utils';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flexDirection: 'row',
gap: 8,
marginTop: 8,
},
button: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
borderRadius: 4,
paddingVertical: 8,
paddingHorizontal: 16,
justifyContent: 'center',
},
buttonText: {
fontSize: 12,
fontWeight: 600,
lineHeight: 16,
color: changeOpacity(theme.centerChannelColor, 0.64),
},
};
});
interface ControlsBarProps {
showStopButton: boolean;
showRegenerateButton: boolean;
onStop: () => void;
onRegenerate: () => void;
}
/**
* Controls bar for agent posts with stop and regenerate buttons
*/
const ControlsBar = ({
showStopButton,
showRegenerateButton,
onStop,
onRegenerate,
}: ControlsBarProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const intl = useIntl();
const handleStop = usePreventDoubleTap(onStop);
const handleRegenerate = usePreventDoubleTap(useCallback(() => {
Alert.alert(
intl.formatMessage({
id: 'agents.regenerate.confirm_title',
defaultMessage: 'Regenerate Response',
}),
intl.formatMessage({
id: 'agents.regenerate.confirm_message',
defaultMessage: 'This will clear the current response and generate a new one. Continue?',
}),
[
{
text: intl.formatMessage({
id: 'agents.regenerate.cancel',
defaultMessage: 'Cancel',
}),
style: 'cancel',
},
{
text: intl.formatMessage({
id: 'agents.regenerate.confirm',
defaultMessage: 'Regenerate',
}),
onPress: onRegenerate,
style: 'destructive',
},
],
);
}, [intl, onRegenerate]));
return (
<View style={styles.container}>
{showStopButton && (
<TouchableOpacity
onPress={handleStop}
style={styles.button}
activeOpacity={0.7}
testID='agents.controls_bar.stop_button'
>
<CompassIcon
name='close-circle-outline'
size={12}
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
<FormattedText
id='agents.controls.stop'
defaultMessage='Stop Generating'
style={styles.buttonText}
/>
</TouchableOpacity>
)}
{showRegenerateButton && (
<TouchableOpacity
onPress={handleRegenerate}
style={styles.button}
activeOpacity={0.7}
testID='agents.controls_bar.regenerate_button'
>
<CompassIcon
name='refresh'
size={12}
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
<FormattedText
id='agents.controls.regenerate'
defaultMessage='Regenerate'
style={styles.buttonText}
/>
</TouchableOpacity>
)}
</View>
);
};
export default ControlsBar;

View file

@ -0,0 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {TOUCH_TARGET_SIZE} from '@agents/constants';
import React, {useCallback, useState} from 'react';
import {Text, TouchableOpacity, View} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import LoadingSpinner from './loading_spinner';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
minimalContainer: {
marginBottom: 4,
minHeight: TOUCH_TARGET_SIZE,
},
minimalContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingVertical: 12,
},
minimalText: {
fontSize: 14,
lineHeight: 20,
color: changeOpacity(theme.centerChannelColor, 0.64),
},
expandedContainer: {
marginBottom: 16,
},
expandedHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 12,
minHeight: TOUCH_TARGET_SIZE,
paddingVertical: 12,
},
expandedHeaderText: {
fontSize: 14,
lineHeight: 20,
color: changeOpacity(theme.centerChannelColor, 0.64),
},
reasoningContentContainer: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.02),
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.08),
borderRadius: 8,
overflow: 'hidden',
},
reasoningContent: {
maxHeight: 600,
},
reasoningText: {
padding: 16,
fontSize: 14,
lineHeight: 22,
color: changeOpacity(theme.centerChannelColor, 0.8),
},
};
});
interface ReasoningDisplayProps {
reasoningSummary: string;
isReasoningLoading: boolean;
}
/**
* Display component for agent reasoning summaries
* Shows collapsible section with reasoning text and loading state
*/
const ReasoningDisplay = ({reasoningSummary, isReasoningLoading}: ReasoningDisplayProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const [isExpanded, setIsExpanded] = useState(false);
const rotation = useSharedValue(0);
const contentOpacity = useSharedValue(0);
const handleToggle = useCallback(() => {
const newExpanded = !isExpanded;
setIsExpanded(newExpanded);
rotation.value = withTiming(newExpanded ? 90 : 0, {duration: 200});
contentOpacity.value = withTiming(newExpanded ? 1 : 0, {duration: 250});
}, [isExpanded, rotation, contentOpacity]);
const chevronAnimatedStyle = useAnimatedStyle(() => ({
transform: [{rotate: `${rotation.value}deg`}],
}));
const contentAnimatedStyle = useAnimatedStyle(() => ({
opacity: contentOpacity.value,
}));
return (
<View style={isExpanded ? styles.expandedContainer : styles.minimalContainer}>
<TouchableOpacity
onPress={handleToggle}
style={isExpanded ? styles.expandedHeader : styles.minimalContent}
activeOpacity={0.7}
>
<Animated.View style={chevronAnimatedStyle}>
<CompassIcon
name='chevron-right'
size={16}
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
</Animated.View>
{isReasoningLoading && (
<LoadingSpinner/>
)}
<FormattedText
id='agents.reasoning.thinking'
defaultMessage='Thinking'
style={isExpanded ? styles.expandedHeaderText : styles.minimalText}
/>
</TouchableOpacity>
{isExpanded && reasoningSummary ? (
<Animated.View style={[styles.reasoningContentContainer, contentAnimatedStyle]}>
<View style={styles.reasoningContent}>
<Text style={styles.reasoningText}>
{reasoningSummary}
</Text>
</View>
</Animated.View>
) : null}
</View>
);
};
export default ReasoningDisplay;

View file

@ -0,0 +1,65 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import {View} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
Easing,
cancelAnimation,
} from 'react-native-reanimated';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
width: 14,
height: 14,
},
spinner: {
width: 14,
height: 14,
borderWidth: 2,
borderRadius: 7,
borderStyle: 'solid',
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
borderTopColor: changeOpacity(theme.centerChannelColor, 0.64),
},
}));
/**
* Animated loading spinner for reasoning generation
* Shows a rotating circular indicator
*/
const LoadingSpinner = () => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const rotation = useSharedValue(0);
useEffect(() => {
rotation.value = withRepeat(
withTiming(360, {duration: 1000, easing: Easing.linear}),
-1,
);
return () => {
cancelAnimation(rotation);
};
}, [rotation]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{rotate: `${rotation.value}deg`}],
}));
return (
<View style={styles.container}>
<Animated.View style={[styles.spinner, animatedStyle]}/>
</View>
);
};
export default LoadingSpinner;

View file

@ -0,0 +1,214 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {submitToolApproval} from '@agents/actions/remote/tool_approval';
import {type ToolCall, ToolCallStatus} from '@agents/types';
import React, {useCallback, useEffect, useState} from 'react';
import {View} from 'react-native';
import FormattedText from '@components/formatted_text';
import Loading from '@components/loading';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import ToolCard from '../tool_card';
interface ToolApprovalSetProps {
postId: string;
toolCalls: ToolCall[];
}
type ToolDecision = {
[toolId: string]: boolean | null; // true = approved, false = rejected, null = undecided
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
marginTop: 8,
marginBottom: 12,
gap: 8,
},
statusBar: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
padding: 12,
marginTop: 8,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
borderRadius: 4,
},
statusText: {
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.64),
},
};
});
/**
* Container component for displaying and managing tool approval requests
*/
const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
const [isSubmitting, setIsSubmitting] = useState(false);
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
const [toolDecisions, setToolDecisions] = useState<ToolDecision>({});
// Clear local decisions when tool status changes from Pending to something else
useEffect(() => {
// Keep only decisions for tools that are still pending
const filterPendingDecisions = (decisions: ToolDecision): ToolDecision => {
const updated: ToolDecision = {};
const prevToolIds = Object.keys(decisions);
for (const toolId of prevToolIds) {
const tool = toolCalls.find((t) => t.id === toolId);
if (tool && tool.status === ToolCallStatus.Pending) {
updated[toolId] = decisions[toolId];
}
}
return updated;
};
setToolDecisions((prev) => {
const updated = filterPendingDecisions(prev);
const updatedCount = Object.keys(updated).length;
const prevCount = Object.keys(prev).length;
return updatedCount === prevCount ? prev : updated;
});
}, [toolCalls]);
const submitDecisions = useCallback(async (decisions: ToolDecision) => {
const approvedToolIds = Object.entries(decisions).
filter(([, isApproved]) => isApproved).
map(([id]) => id);
setIsSubmitting(true);
const {error} = await submitToolApproval(serverUrl, postId, approvedToolIds);
// Reset submitting state regardless of success/error
// On error, user can try again. On success, backend updates via POST_EDITED
setIsSubmitting(false);
return !error;
}, [serverUrl, postId]);
const handleToolDecision = useCallback(async (toolId: string, approved: boolean) => {
if (isSubmitting) {
return;
}
const updatedDecisions = {
...toolDecisions,
[toolId]: approved,
};
setToolDecisions(updatedDecisions);
// Check if there are still undecided tools
const hasUndecided = toolCalls.some((tool) => {
return !(tool.id in updatedDecisions) || updatedDecisions[tool.id] === null;
});
if (!hasUndecided) {
await submitDecisions(updatedDecisions);
}
}, [isSubmitting, toolDecisions, toolCalls, submitDecisions]);
const handleApprove = useCallback((toolId: string) => {
handleToolDecision(toolId, true);
}, [handleToolDecision]);
const handleReject = useCallback((toolId: string) => {
handleToolDecision(toolId, false);
}, [handleToolDecision]);
const toggleCollapse = useCallback((toolId: string) => {
const tool = toolCalls.find((t) => t.id === toolId);
const defaultExpanded = tool?.status === ToolCallStatus.Pending;
setExpandedTools((prev) => ({
...prev,
[toolId]: !(prev[toolId] ?? defaultExpanded),
}));
}, [toolCalls]);
if (toolCalls.length === 0) {
return null;
}
// Get pending tool calls
const pendingToolCalls = toolCalls.filter((call) => call.status === ToolCallStatus.Pending);
// Get processed tool calls
const processedToolCalls = toolCalls.filter((call) => call.status !== ToolCallStatus.Pending);
// Calculate how many pending tools haven't been decided yet
const undecidedCount = pendingToolCalls.filter(
(tool) => !(tool.id in toolDecisions),
).length;
// Helper to compute if a tool should be collapsed
const isToolCollapsed = (tool: ToolCall) => {
const defaultExpanded = tool.status === ToolCallStatus.Pending;
return !(expandedTools[tool.id] ?? defaultExpanded);
};
return (
<View style={styles.container}>
{pendingToolCalls.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
isCollapsed={isToolCollapsed(tool)}
isProcessing={isSubmitting}
localDecision={toolDecisions[tool.id]}
onToggleCollapse={toggleCollapse}
onApprove={handleApprove}
onReject={handleReject}
/>
))}
{processedToolCalls.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
isCollapsed={isToolCollapsed(tool)}
isProcessing={false}
onToggleCollapse={toggleCollapse}
/>
))}
{/* Only show status bar for multiple pending tools */}
{pendingToolCalls.length > 1 && isSubmitting && (
<View style={styles.statusBar}>
<Loading
size='small'
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
<FormattedText
id='agents.tool_call.submitting'
defaultMessage='Submitting...'
style={styles.statusText}
/>
</View>
)}
{/* Only show status counter for multiple pending tools that haven't been submitted yet */}
{pendingToolCalls.length > 1 && undecidedCount > 0 && !isSubmitting && (
<View style={styles.statusBar}>
<FormattedText
id='agents.tool_call.pending_decisions'
defaultMessage='{count, plural, =0 {All tools decided} one {# tool needs a decision} other {# tools need decisions}}'
values={{count: undecidedCount}}
style={styles.statusText}
/>
</View>
)}
</View>
);
};
export default ToolApprovalSet;

View file

@ -0,0 +1,363 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ToolCallStatus, type ToolCall} from '@agents/types';
import React, {useCallback, useMemo} from 'react';
import {Text, TouchableOpacity, View} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import Loading from '@components/loading';
import Markdown from '@components/markdown';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {safeParseJSON} from '@utils/helpers';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
interface ToolCardProps {
tool: ToolCall;
isCollapsed: boolean;
isProcessing: boolean;
localDecision?: boolean | null; // true = approved, false = rejected, null/undefined = undecided
onToggleCollapse: (toolId: string) => void;
onApprove?: (toolId: string) => void;
onReject?: (toolId: string) => void;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
marginBottom: 4,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingVertical: 8,
},
chevronIcon: {
width: 12,
},
statusIcon: {
width: 12,
height: 12,
},
toolName: {
fontSize: 14,
lineHeight: 20,
color: changeOpacity(theme.centerChannelColor, 0.75),
flex: 1,
},
argumentsContainer: {
marginLeft: 24,
},
markdownText: {
fontSize: 11,
lineHeight: 16,
color: changeOpacity(theme.centerChannelColor, 0.75),
},
responseLabel: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingTop: 8,
paddingLeft: 24,
},
responseLabelText: {
fontSize: 14,
fontWeight: 600,
lineHeight: 20,
color: changeOpacity(theme.centerChannelColor, 0.75),
},
resultContainer: {
marginLeft: 24,
},
statusContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginTop: 16,
paddingLeft: 24,
},
statusText: {
fontSize: 14,
lineHeight: 20,
color: changeOpacity(theme.centerChannelColor, 0.75),
},
buttonContainer: {
flexDirection: 'row',
gap: 8,
marginTop: 4,
paddingLeft: 36,
},
button: {
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
borderRadius: 4,
paddingVertical: 8,
paddingHorizontal: 16,
justifyContent: 'center',
},
buttonDisabled: {
opacity: 0.5,
},
buttonText: {
fontSize: 12,
fontWeight: 600,
lineHeight: 16,
color: theme.buttonBg,
},
};
});
/**
* Individual tool card component showing tool details and approval buttons
*/
const ToolCard = ({
tool,
isCollapsed,
isProcessing,
localDecision,
onToggleCollapse,
onApprove,
onReject,
}: ToolCardProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const contentOpacity = useSharedValue(isCollapsed ? 0 : 1);
const chevronRotation = useSharedValue(isCollapsed ? 0 : 90);
const isPending = tool.status === ToolCallStatus.Pending;
const hasLocalDecision = localDecision !== undefined && localDecision !== null;
const isSuccess = tool.status === ToolCallStatus.Success;
const isError = tool.status === ToolCallStatus.Error;
const isRejected = tool.status === ToolCallStatus.Rejected;
// Convert underscores to spaces and capitalize first letter of each word
const displayName = useMemo(() => {
return tool.name.
replace(/_/g, ' ').
replace(/\b\w/g, (char) => char.toUpperCase());
}, [tool.name]);
// Render arguments as JSON code block
const argumentsMarkdown = useMemo(() => {
return `\`\`\`json\n${JSON.stringify(tool.arguments, null, 2)}\n\`\`\``;
}, [tool.arguments]);
// Render result as code block - try to detect if it's JSON
const resultMarkdown = useMemo(() => {
if (!tool.result) {
return '';
}
const parsed = safeParseJSON(tool.result);
if (typeof parsed === 'object' && parsed !== null) {
return `\`\`\`json\n${tool.result}\n\`\`\``;
}
return `\`\`\`\n${tool.result}\n\`\`\``;
}, [tool.result]);
const handleToggle = useCallback(() => {
const newCollapsed = !isCollapsed;
contentOpacity.value = withTiming(newCollapsed ? 0 : 1, {duration: 200});
chevronRotation.value = withTiming(newCollapsed ? 0 : 90, {duration: 200});
onToggleCollapse(tool.id);
}, [isCollapsed, contentOpacity, chevronRotation, onToggleCollapse, tool.id]);
const handleApprove = useCallback(() => {
onApprove?.(tool.id);
}, [onApprove, tool.id]);
const handleReject = useCallback(() => {
onReject?.(tool.id);
}, [onReject, tool.id]);
const chevronAnimatedStyle = useAnimatedStyle(() => ({
transform: [{rotate: `${chevronRotation.value}deg`}],
}));
const contentAnimatedStyle = useAnimatedStyle(() => ({
opacity: contentOpacity.value,
}));
// Determine icon based on status
const getStatusIcon = () => {
if (isPending) {
return (
<Loading
size='small'
color={changeOpacity(theme.centerChannelColor, 0.64)}
containerStyle={styles.statusIcon}
/>
);
}
if (isSuccess) {
return (
<CompassIcon
name='check-circle'
size={12}
color={theme.onlineIndicator}
style={styles.statusIcon}
/>
);
}
if (isError) {
return (
<CompassIcon
name='alert-circle-outline'
size={12}
color={theme.errorTextColor}
style={styles.statusIcon}
/>
);
}
if (isRejected) {
return (
<CompassIcon
name='close-circle-outline'
size={12}
color={theme.dndIndicator}
style={styles.statusIcon}
/>
);
}
return null;
};
return (
<View style={styles.container}>
<TouchableOpacity
onPress={handleToggle}
style={styles.header}
activeOpacity={0.7}
>
<Animated.View style={[styles.chevronIcon, chevronAnimatedStyle]}>
<CompassIcon
name='chevron-right'
size={16}
color={changeOpacity(theme.centerChannelColor, 0.56)}
/>
</Animated.View>
{getStatusIcon()}
<Text
style={styles.toolName}
numberOfLines={1}
>
{displayName}
</Text>
</TouchableOpacity>
{!isCollapsed && (
<Animated.View style={contentAnimatedStyle}>
<View style={styles.argumentsContainer}>
<Markdown
baseTextStyle={styles.markdownText}
value={argumentsMarkdown}
theme={theme}
location={Screens.CHANNEL}
/>
</View>
{(isSuccess || isError) && resultMarkdown && (
<>
<View style={styles.responseLabel}>
{isSuccess && (
<CompassIcon
name='check-circle'
size={12}
color={theme.onlineIndicator}
/>
)}
{isError && (
<CompassIcon
name='alert-circle-outline'
size={12}
color={theme.errorTextColor}
/>
)}
<FormattedText
id='agents.tool_call.response'
defaultMessage='Response'
style={styles.responseLabelText}
/>
</View>
<View style={styles.resultContainer}>
<Markdown
baseTextStyle={styles.markdownText}
value={resultMarkdown}
theme={theme}
location={Screens.CHANNEL}
/>
</View>
</>
)}
{isRejected && (
<View style={styles.statusContainer}>
<CompassIcon
name='close-circle-outline'
size={12}
color={theme.dndIndicator}
/>
<FormattedText
id='agents.tool_call.status.rejected'
defaultMessage='Rejected'
style={styles.statusText}
/>
</View>
)}
</Animated.View>
)}
{isPending && !hasLocalDecision && isProcessing && (
<View style={styles.statusContainer}>
<Loading
size='small'
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
<FormattedText
id='agents.tool_call.processing'
defaultMessage='Processing...'
style={styles.statusText}
/>
</View>
)}
{isPending && !hasLocalDecision && !isProcessing && (
<View style={styles.buttonContainer}>
<TouchableOpacity
onPress={handleApprove}
disabled={isProcessing}
style={[styles.button, isProcessing && styles.buttonDisabled]}
activeOpacity={0.7}
>
<FormattedText
id='agents.tool_call.approve'
defaultMessage='Accept'
style={styles.buttonText}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={handleReject}
disabled={isProcessing}
style={[styles.button, isProcessing && styles.buttonDisabled]}
activeOpacity={0.7}
>
<FormattedText
id='agents.tool_call.reject'
defaultMessage='Reject'
style={styles.buttonText}
/>
</TouchableOpacity>
</View>
)}
</View>
);
};
export default ToolCard;

View file

@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* Agent post types
*/
export const AGENT_POST_TYPES = {
LLMBOT: 'custom_llmbot',
LLM_POSTBACK: 'custom_llm_postback',
} as const;
/**
* Minimum touch target size for accessibility (44pt per iOS HIG)
*/
export const TOUCH_TARGET_SIZE = 44;
/**
* WebSocket event name for agent post updates
*/
export const AGENT_WEBSOCKET_EVENT = 'custom_mattermost-ai_postupdate';
/**
* Control signal values from WebSocket messages
*/
export const CONTROL_SIGNALS = {
START: 'start',
END: 'end',
CANCEL: 'cancel',
REASONING_SUMMARY: 'reasoning_summary',
REASONING_SUMMARY_DONE: 'reasoning_summary_done',
TOOL_CALL: 'tool_call',
ANNOTATIONS: 'annotations',
} as const;

View file

@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default as streamingStore, useStreamingState} from './streaming_store';

View file

@ -0,0 +1,634 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {CONTROL_SIGNALS} from '@agents/constants';
import {ToolCallStatus, type PostUpdateWebsocketMessage, type ToolCall, type Annotation, type StreamingState} from '@agents/types';
describe('StreamingPostStore', () => {
let streamingStore: typeof import('./streaming_store').default;
beforeEach(() => {
jest.resetModules();
streamingStore = require('./streaming_store').default;
});
afterEach(() => {
jest.resetModules();
});
describe('startStreaming', () => {
it('should create initial state with precontent true', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
const state = streamingStore.getStreamingState(postId);
expect(state).toBeDefined();
expect(state?.postId).toBe(postId);
expect(state?.generating).toBe(true);
expect(state?.message).toBe('');
expect(state?.precontent).toBe(true);
expect(state?.reasoning).toBe('');
expect(state?.isReasoningLoading).toBe(false);
expect(state?.showReasoning).toBe(false);
expect(state?.toolCalls).toEqual([]);
expect(state?.annotations).toEqual([]);
});
it('should emit state to observers', () => {
const postId = 'post123';
const receivedStates: Array<StreamingState | undefined> = [];
const subscription = streamingStore.observeStreamingState(postId).subscribe((state) => {
receivedStates.push(state);
});
streamingStore.startStreaming(postId);
// BehaviorSubject emits initial undefined, then the started state
expect(receivedStates).toHaveLength(2);
expect(receivedStates[0]).toBeUndefined();
expect(receivedStates[1]).toMatchObject({
postId,
generating: true,
message: '',
precontent: true,
});
subscription.unsubscribe();
});
});
describe('updateMessage', () => {
it('should update message and set precontent to false', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
streamingStore.updateMessage(postId, 'Hello world');
const state = streamingStore.getStreamingState(postId);
expect(state?.message).toBe('Hello world');
expect(state?.precontent).toBe(false);
expect(state?.generating).toBe(true);
});
it('should emit updated state to observers', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
const receivedStates: Array<StreamingState | undefined> = [];
const subscription = streamingStore.observeStreamingState(postId).subscribe((state) => {
receivedStates.push(state);
});
streamingStore.updateMessage(postId, 'Hello world');
// First emission is current state (from subscribe), second is update
expect(receivedStates).toHaveLength(2);
expect(receivedStates[1]).toMatchObject({
postId,
message: 'Hello world',
precontent: false,
generating: true,
});
subscription.unsubscribe();
});
it('should do nothing if post is not streaming', () => {
const postId = 'post123';
streamingStore.updateMessage(postId, 'Hello world');
const state = streamingStore.getStreamingState(postId);
expect(state).toBeUndefined();
});
});
describe('endStreaming', () => {
it('should preserve message but mark as not generating', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
streamingStore.updateMessage(postId, 'Hello world');
streamingStore.endStreaming(postId);
// State should be preserved with generating: false
const state = streamingStore.getStreamingState(postId);
expect(state).toBeDefined();
expect(state?.message).toBe('Hello world');
expect(state?.generating).toBe(false);
expect(state?.precontent).toBe(false);
expect(state?.isReasoningLoading).toBe(false);
});
it('should emit preserved state and keep subject alive for reuse', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
streamingStore.updateMessage(postId, 'Hello world');
const receivedStates: Array<StreamingState | undefined> = [];
let completed = false;
const subscription = streamingStore.observeStreamingState(postId).subscribe({
next: (state) => receivedStates.push(state),
complete: () => {
completed = true;
},
});
streamingStore.endStreaming(postId);
// Should receive current state on subscribe, then state with generating: false
expect(receivedStates).toHaveLength(2);
expect(receivedStates[1]).toMatchObject({
postId,
message: 'Hello world',
generating: false,
});
// Subject should NOT complete - kept alive for potential reuse (regenerate)
expect(completed).toBe(false);
// Verify subject can receive new streaming updates (regenerate scenario)
streamingStore.startStreaming(postId);
expect(receivedStates).toHaveLength(3);
expect(receivedStates[2]).toMatchObject({postId, generating: true, precontent: true});
subscription.unsubscribe();
});
it('should do nothing if post is not streaming', () => {
const postId = 'post123';
// Should not throw
expect(() => streamingStore.endStreaming(postId)).not.toThrow();
});
});
describe('updateReasoning', () => {
it('should update reasoning and set showReasoning to true', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
streamingStore.updateReasoning(postId, 'Analyzing the question...', true);
const state = streamingStore.getStreamingState(postId);
expect(state?.reasoning).toBe('Analyzing the question...');
expect(state?.isReasoningLoading).toBe(true);
expect(state?.showReasoning).toBe(true);
});
it('should set generating to false when isLoading is true', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
streamingStore.updateReasoning(postId, 'Analyzing...', true);
const state = streamingStore.getStreamingState(postId);
expect(state?.generating).toBe(false);
expect(state?.precontent).toBe(false);
});
it('should preserve generating state when isLoading is false', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
streamingStore.updateMessage(postId, 'Some text');
streamingStore.updateReasoning(postId, 'Reasoning complete', false);
const state = streamingStore.getStreamingState(postId);
expect(state?.generating).toBe(true);
expect(state?.isReasoningLoading).toBe(false);
});
it('should do nothing if post is not streaming', () => {
const postId = 'post123';
streamingStore.updateReasoning(postId, 'Reasoning', true);
const state = streamingStore.getStreamingState(postId);
expect(state).toBeUndefined();
});
});
describe('updateToolCalls', () => {
it('should parse JSON and update tool calls', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
const toolCalls: ToolCall[] = [
{
id: 'tool1',
name: 'search',
description: 'Search for information',
arguments: {query: 'test'},
status: ToolCallStatus.Pending,
},
];
streamingStore.updateToolCalls(postId, JSON.stringify(toolCalls));
const state = streamingStore.getStreamingState(postId);
expect(state?.toolCalls).toEqual(toolCalls);
expect(state?.precontent).toBe(false);
});
it('should silently handle JSON parse errors', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
streamingStore.updateToolCalls(postId, 'invalid json');
const state = streamingStore.getStreamingState(postId);
expect(state?.toolCalls).toEqual([]);
});
it('should create minimal state if post is not streaming', () => {
const postId = 'post123';
streamingStore.updateToolCalls(postId, '[]');
const state = streamingStore.getStreamingState(postId);
expect(state).toBeDefined();
expect(state?.toolCalls).toEqual([]);
expect(state?.generating).toBe(false);
});
});
describe('updateAnnotations', () => {
it('should parse JSON and update annotations', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
const annotations: Annotation[] = [
{
type: 'citation',
start_index: 0,
end_index: 10,
url: 'https://example.com',
title: 'Example',
index: 1,
},
];
streamingStore.updateAnnotations(postId, JSON.stringify(annotations));
const state = streamingStore.getStreamingState(postId);
expect(state?.annotations).toEqual(annotations);
expect(state?.precontent).toBe(false);
});
it('should silently handle JSON parse errors', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
streamingStore.updateAnnotations(postId, 'invalid json');
const state = streamingStore.getStreamingState(postId);
expect(state?.annotations).toEqual([]);
});
it('should do nothing if post is not streaming', () => {
const postId = 'post123';
streamingStore.updateAnnotations(postId, '[]');
const state = streamingStore.getStreamingState(postId);
expect(state).toBeUndefined();
});
});
describe('handleWebSocketMessage', () => {
it('should handle START control signal', () => {
const message: PostUpdateWebsocketMessage = {
post_id: 'post123',
control: CONTROL_SIGNALS.START,
};
streamingStore.handleWebSocketMessage(message);
const state = streamingStore.getStreamingState('post123');
expect(state).toBeDefined();
expect(state?.generating).toBe(true);
expect(state?.precontent).toBe(true);
});
it('should handle END control signal', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
streamingStore.updateMessage(postId, 'Hello');
const message: PostUpdateWebsocketMessage = {
post_id: postId,
control: CONTROL_SIGNALS.END,
};
streamingStore.handleWebSocketMessage(message);
// State should be preserved with generating: false
const state = streamingStore.getStreamingState(postId);
expect(state).toBeDefined();
expect(state?.message).toBe('Hello');
expect(state?.generating).toBe(false);
});
it('should handle CANCEL control signal', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
streamingStore.updateMessage(postId, 'Hello');
const message: PostUpdateWebsocketMessage = {
post_id: postId,
control: CONTROL_SIGNALS.CANCEL,
};
streamingStore.handleWebSocketMessage(message);
// State should be preserved with generating: false
const state = streamingStore.getStreamingState(postId);
expect(state).toBeDefined();
expect(state?.message).toBe('Hello');
expect(state?.generating).toBe(false);
});
it('should handle REASONING_SUMMARY control signal', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
const message: PostUpdateWebsocketMessage = {
post_id: postId,
control: CONTROL_SIGNALS.REASONING_SUMMARY,
reasoning: 'Thinking step 1...',
};
streamingStore.handleWebSocketMessage(message);
const state = streamingStore.getStreamingState(postId);
expect(state?.reasoning).toBe('Thinking step 1...');
expect(state?.isReasoningLoading).toBe(true);
expect(state?.showReasoning).toBe(true);
expect(state?.generating).toBe(false);
});
it('should handle REASONING_SUMMARY_DONE with reasoning text', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
streamingStore.updateReasoning(postId, 'Step 1', true);
const message: PostUpdateWebsocketMessage = {
post_id: postId,
control: CONTROL_SIGNALS.REASONING_SUMMARY_DONE,
reasoning: 'Final reasoning',
};
streamingStore.handleWebSocketMessage(message);
const state = streamingStore.getStreamingState(postId);
expect(state?.reasoning).toBe('Final reasoning');
expect(state?.isReasoningLoading).toBe(false);
});
it('should handle REASONING_SUMMARY_DONE without reasoning text', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
streamingStore.updateReasoning(postId, 'Existing reasoning', true);
const message: PostUpdateWebsocketMessage = {
post_id: postId,
control: CONTROL_SIGNALS.REASONING_SUMMARY_DONE,
};
streamingStore.handleWebSocketMessage(message);
const state = streamingStore.getStreamingState(postId);
expect(state?.reasoning).toBe('Existing reasoning');
expect(state?.isReasoningLoading).toBe(false);
});
it('should handle TOOL_CALL control signal', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
const toolCalls: ToolCall[] = [
{
id: 'tool1',
name: 'search',
description: 'Search',
arguments: {query: 'test'},
status: ToolCallStatus.Pending,
},
];
const message: PostUpdateWebsocketMessage = {
post_id: postId,
control: CONTROL_SIGNALS.TOOL_CALL,
tool_call: JSON.stringify(toolCalls),
};
streamingStore.handleWebSocketMessage(message);
const state = streamingStore.getStreamingState(postId);
expect(state?.toolCalls).toEqual(toolCalls);
});
it('should handle ANNOTATIONS control signal', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
const annotations: Annotation[] = [
{
type: 'citation',
start_index: 0,
end_index: 10,
url: 'https://example.com',
title: 'Example',
index: 1,
},
];
const message: PostUpdateWebsocketMessage = {
post_id: postId,
control: CONTROL_SIGNALS.ANNOTATIONS,
annotations: JSON.stringify(annotations),
};
streamingStore.handleWebSocketMessage(message);
const state = streamingStore.getStreamingState(postId);
expect(state?.annotations).toEqual(annotations);
});
it('should handle message updates with next field', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
const message: PostUpdateWebsocketMessage = {
post_id: postId,
next: 'Hello from agent',
};
streamingStore.handleWebSocketMessage(message);
const state = streamingStore.getStreamingState(postId);
expect(state?.message).toBe('Hello from agent');
expect(state?.precontent).toBe(false);
expect(state?.generating).toBe(true);
});
it('should ignore messages without post_id', () => {
const message: PostUpdateWebsocketMessage = {
post_id: '',
next: 'Hello',
};
streamingStore.handleWebSocketMessage(message);
// Nothing should be created
expect(streamingStore.getStreamingState('')).toBeUndefined();
});
});
describe('observeStreamingState', () => {
it('should emit current state immediately on subscribe', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
let receivedState: StreamingState | undefined;
const subscription = streamingStore.observeStreamingState(postId).subscribe((state) => {
receivedState = state;
});
expect(receivedState).toBeDefined();
expect(receivedState?.postId).toBe(postId);
subscription.unsubscribe();
});
it('should emit undefined for non-streaming post', () => {
const postId = 'nonexistent';
let receivedState: StreamingState | undefined = {postId: 'sentinel'} as StreamingState;
const subscription = streamingStore.observeStreamingState(postId).subscribe((state) => {
receivedState = state;
});
expect(receivedState).toBeUndefined();
subscription.unsubscribe();
});
});
describe('getStreamingState', () => {
it('should return state for streaming post', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
const state = streamingStore.getStreamingState(postId);
expect(state).toBeDefined();
expect(state?.postId).toBe(postId);
});
it('should return undefined for non-streaming post', () => {
const state = streamingStore.getStreamingState('nonexistent');
expect(state).toBeUndefined();
});
});
describe('isStreaming', () => {
it('should return true for streaming post', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
expect(streamingStore.isStreaming(postId)).toBe(true);
});
it('should return false for non-streaming post', () => {
expect(streamingStore.isStreaming('nonexistent')).toBe(false);
});
});
describe('removePost', () => {
it('should remove streaming state and emit undefined', () => {
const postId = 'post123';
streamingStore.startStreaming(postId);
const receivedStates: Array<StreamingState | undefined> = [];
const subscription = streamingStore.observeStreamingState(postId).subscribe((state) => {
receivedStates.push(state);
});
streamingStore.removePost(postId);
expect(streamingStore.getStreamingState(postId)).toBeUndefined();
expect(receivedStates[receivedStates.length - 1]).toBeUndefined();
subscription.unsubscribe();
});
it('should do nothing if post is not streaming', () => {
const postId = 'post123';
// Should not throw
expect(() => streamingStore.removePost(postId)).not.toThrow();
});
});
describe('clear', () => {
it('should clear all streaming state', () => {
streamingStore.startStreaming('post1');
streamingStore.startStreaming('post2');
streamingStore.startStreaming('post3');
expect(streamingStore.isStreaming('post1')).toBe(true);
expect(streamingStore.isStreaming('post2')).toBe(true);
expect(streamingStore.isStreaming('post3')).toBe(true);
streamingStore.clear();
expect(streamingStore.isStreaming('post1')).toBe(false);
expect(streamingStore.isStreaming('post2')).toBe(false);
expect(streamingStore.isStreaming('post3')).toBe(false);
});
it('should complete all subscriptions', () => {
streamingStore.startStreaming('post1');
streamingStore.startStreaming('post2');
let completedCount = 0;
const sub1 = streamingStore.observeStreamingState('post1').subscribe({
complete: () => completedCount++,
});
const sub2 = streamingStore.observeStreamingState('post2').subscribe({
complete: () => completedCount++,
});
streamingStore.clear();
expect(completedCount).toBe(2);
sub1.unsubscribe();
sub2.unsubscribe();
});
});
describe('multiple concurrent streams', () => {
it('should handle multiple posts streaming simultaneously', () => {
streamingStore.startStreaming('post1');
streamingStore.startStreaming('post2');
streamingStore.updateMessage('post1', 'Message 1');
streamingStore.updateMessage('post2', 'Message 2');
const state1 = streamingStore.getStreamingState('post1');
const state2 = streamingStore.getStreamingState('post2');
expect(state1?.message).toBe('Message 1');
expect(state2?.message).toBe('Message 2');
});
it('should handle ending one stream while others continue', () => {
streamingStore.startStreaming('post1');
streamingStore.startStreaming('post2');
streamingStore.endStreaming('post1');
expect(streamingStore.isStreaming('post1')).toBe(false);
expect(streamingStore.isStreaming('post2')).toBe(true);
});
});
});

View file

@ -0,0 +1,294 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {CONTROL_SIGNALS} from '@agents/constants';
import {type StreamingState, type PostUpdateWebsocketMessage} from '@agents/types';
import {useEffect, useState} from 'react';
import {BehaviorSubject, type Observable} from 'rxjs';
import {logWarning} from '@utils/log';
/**
* Ephemeral store for managing streaming post state
* Uses RxJS BehaviorSubject for reactive state management
*/
class StreamingPostStore {
private streamingSubjects: Map<string, BehaviorSubject<StreamingState | undefined>> = new Map();
/**
* Get or create a BehaviorSubject for a post
*/
private getSubject(postId: string): BehaviorSubject<StreamingState | undefined> {
let subject = this.streamingSubjects.get(postId);
if (!subject) {
subject = new BehaviorSubject<StreamingState | undefined>(undefined);
this.streamingSubjects.set(postId, subject);
}
return subject;
}
/**
* Start streaming for a post
*/
startStreaming(postId: string): void {
const state: StreamingState = {
postId,
generating: true,
message: '',
precontent: true, // Show "Starting..." state
reasoning: '',
isReasoningLoading: false,
showReasoning: false,
toolCalls: [],
annotations: [],
};
this.getSubject(postId).next(state);
}
/**
* Update the streaming message
*/
updateMessage(postId: string, message: string): void {
const state = this.getStreamingState(postId);
if (!state) {
return;
}
this.getSubject(postId).next({
...state,
message,
precontent: false,
generating: true,
});
}
/**
* End streaming for a post
* Preserves message but marks as done - POST_EDITED will call removePost to clear
*/
endStreaming(postId: string): void {
const state = this.getStreamingState(postId);
if (!state) {
return;
}
// Preserve message but mark as done - wait for POST_EDITED to clear via removePost
this.getSubject(postId).next({
...state,
generating: false,
precontent: false,
isReasoningLoading: false,
});
}
/**
* Update reasoning summary
*/
updateReasoning(postId: string, reasoning: string, isLoading: boolean): void {
const state = this.getStreamingState(postId);
if (!state) {
return;
}
// During reasoning, explicitly set generating to false to prevent blinking cursor
const generating = isLoading ? false : state.generating;
const precontent = isLoading ? false : state.precontent;
this.getSubject(postId).next({
...state,
reasoning,
isReasoningLoading: isLoading,
showReasoning: true,
generating,
precontent,
});
}
/**
* Update tool calls
*/
updateToolCalls(postId: string, toolCallsJson: string): void {
let state = this.getStreamingState(postId);
// If no streaming state exists, create a minimal one for tool call updates
// This handles tool status updates that arrive after streaming ends but before POST_EDITED
if (!state) {
state = {
postId,
generating: false,
message: '',
precontent: false,
reasoning: '',
isReasoningLoading: false,
showReasoning: false,
toolCalls: [],
annotations: [],
};
}
try {
const toolCalls = JSON.parse(toolCallsJson);
this.getSubject(postId).next({
...state,
toolCalls,
precontent: false,
});
} catch (error) {
logWarning('[StreamingPostStore.updateToolCalls]', error, {postId, toolCallsJson});
}
}
/**
* Update annotations/citations
*/
updateAnnotations(postId: string, annotationsJson: string): void {
const state = this.getStreamingState(postId);
if (!state) {
return;
}
try {
const annotations = JSON.parse(annotationsJson);
this.getSubject(postId).next({
...state,
annotations,
precontent: false,
});
} catch (error) {
logWarning('[StreamingPostStore.updateAnnotations]', error, {postId, annotationsJson});
}
}
/**
* Handle a WebSocket message
*/
handleWebSocketMessage(data: PostUpdateWebsocketMessage): void {
const {post_id, next, control, reasoning, tool_call, annotations} = data;
if (!post_id) {
return;
}
// Handle control signals
if (control === CONTROL_SIGNALS.START) {
this.startStreaming(post_id);
return;
}
if (control === CONTROL_SIGNALS.END || control === CONTROL_SIGNALS.CANCEL) {
this.endStreaming(post_id);
return;
}
// Handle reasoning summary updates
if (control === CONTROL_SIGNALS.REASONING_SUMMARY && reasoning) {
// Replace entire reasoning with accumulated text from backend
this.updateReasoning(post_id, reasoning, true);
return;
}
if (control === CONTROL_SIGNALS.REASONING_SUMMARY_DONE) {
// Final reasoning text - mark as complete
const state = this.getStreamingState(post_id);
if (state && reasoning) {
this.updateReasoning(post_id, reasoning, false);
} else if (state) {
// Just mark as done if no new reasoning text
this.getSubject(post_id).next({
...state,
isReasoningLoading: false,
});
}
return;
}
// Handle tool call events
if (control === CONTROL_SIGNALS.TOOL_CALL && tool_call) {
this.updateToolCalls(post_id, tool_call);
return;
}
// Handle annotation events
if (control === CONTROL_SIGNALS.ANNOTATIONS && annotations) {
this.updateAnnotations(post_id, annotations);
return;
}
// Handle message updates
if (next) {
// Message comes as full accumulated text, not delta
this.updateMessage(post_id, next);
}
}
/**
* Get the current streaming state for a post (synchronous)
*/
getStreamingState(postId: string): StreamingState | undefined {
return this.streamingSubjects.get(postId)?.value;
}
/**
* Observe streaming state for a post (reactive)
*/
observeStreamingState(postId: string): Observable<StreamingState | undefined> {
return this.getSubject(postId).asObservable();
}
/**
* Check if a post is currently streaming (actively generating)
*/
isStreaming(postId: string): boolean {
const state = this.getStreamingState(postId);
return state?.generating ?? false;
}
/**
* Remove streaming state for a specific post
* Call this when a post is updated via POST_EDITED to ensure
* the component uses the persisted data from the database
*/
removePost(postId: string): void {
const subject = this.streamingSubjects.get(postId);
if (!subject) {
return;
}
// Signal end to subscribers, but keep subject alive for potential reuse
subject.next(undefined);
}
/**
* Clear all streaming state (e.g., on logout)
*/
clear(): void {
// Complete all subjects before clearing
for (const subject of this.streamingSubjects.values()) {
subject.next(undefined);
subject.complete();
}
this.streamingSubjects.clear();
}
}
// Singleton instance
const streamingStore = new StreamingPostStore();
/**
* React hook to subscribe to streaming state for a post
*/
export function useStreamingState(postId: string): StreamingState | undefined {
const [state, setState] = useState<StreamingState | undefined>(
() => streamingStore.getStreamingState(postId),
);
useEffect(() => {
const subscription = streamingStore.observeStreamingState(postId).subscribe(setState);
return () => subscription.unsubscribe();
}, [postId]);
return state;
}
export default streamingStore;

View file

@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* Tool call status values
*/
export const ToolCallStatus = {
Pending: 0,
Accepted: 1,
Rejected: 2,
Error: 3,
Success: 4,
} as const;
// eslint-disable-next-line @typescript-eslint/no-redeclare -- TypeScript supports same-name type/value pairs as enum alternative
export type ToolCallStatus = typeof ToolCallStatus[keyof typeof ToolCallStatus];
/**
* Tool call data structure
*/
export interface ToolCall {
id: string;
name: string;
description: string;
arguments: any;
result?: string;
status: ToolCallStatus;
}
/**
* Citation/annotation data structure
*/
export interface Annotation {
type: string;
start_index: number;
end_index: number;
url: string;
title: string;
cited_text?: string;
index: number;
}
/**
* WebSocket message data for agent post updates
*/
export interface PostUpdateWebsocketMessage {
post_id: string;
next?: string; // Full accumulated message text
control?: string; // Control signals: 'start', 'end', 'cancel', 'reasoning_summary', 'tool_call', 'annotations'
tool_call?: string; // JSON-encoded tool calls
reasoning?: string; // Reasoning summary text
annotations?: string; // JSON-encoded citations
}
/**
* Streaming state for an active agent post
*/
export interface StreamingState {
postId: string;
generating: boolean;
message: string;
precontent: boolean; // True during 'start' before first content
reasoning: string; // Accumulated reasoning text
isReasoningLoading: boolean; // True while reasoning is being generated
showReasoning: boolean; // True if reasoning should be displayed
toolCalls: ToolCall[]; // Tool calls pending approval or processed
annotations: Annotation[]; // Citations/annotations for the post
}

View file

@ -0,0 +1,134 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AGENT_POST_TYPES} from '@agents/constants';
import TestHelper from '@test/test_helper';
import {isAgentPost, isPostRequester} from './utils';
describe('isAgentPost', () => {
describe('with Post objects', () => {
it('returns true when post type is custom_llmbot', () => {
const post = TestHelper.fakePost({type: AGENT_POST_TYPES.LLMBOT});
expect(isAgentPost(post)).toBe(true);
});
it('returns true when post type is custom_llm_postback', () => {
const post = TestHelper.fakePost({type: AGENT_POST_TYPES.LLM_POSTBACK});
expect(isAgentPost(post)).toBe(true);
});
it('returns false for non-agent post types', () => {
const post = TestHelper.fakePost({type: ''});
expect(isAgentPost(post)).toBe(false);
const systemPost = TestHelper.fakePost({type: 'system_join_channel'});
expect(isAgentPost(systemPost)).toBe(false);
});
});
describe('with PostModel objects', () => {
it('returns true when post type is custom_llmbot', () => {
const postModel = TestHelper.fakePostModel({type: AGENT_POST_TYPES.LLMBOT});
expect(isAgentPost(postModel)).toBe(true);
});
it('returns true when post type is custom_llm_postback', () => {
const postModel = TestHelper.fakePostModel({type: AGENT_POST_TYPES.LLM_POSTBACK});
expect(isAgentPost(postModel)).toBe(true);
});
it('returns false for non-agent post types', () => {
const postModel = TestHelper.fakePostModel({type: ''});
expect(isAgentPost(postModel)).toBe(false);
});
});
});
describe('isPostRequester', () => {
const currentUserId = 'user123';
describe('with Post objects', () => {
it('returns true when llm_requester_user_id matches current user ID', () => {
const post = TestHelper.fakePost({
props: {
llm_requester_user_id: currentUserId,
},
});
expect(isPostRequester(post, currentUserId)).toBe(true);
});
it('returns false when llm_requester_user_id does not match', () => {
const post = TestHelper.fakePost({
props: {
llm_requester_user_id: 'different_user',
},
});
expect(isPostRequester(post, currentUserId)).toBe(false);
});
it('returns false when props is undefined', () => {
const post = TestHelper.fakePost({
props: undefined,
});
expect(isPostRequester(post, currentUserId)).toBe(false);
});
it('returns false when props is empty object', () => {
const post = TestHelper.fakePost({
props: {},
});
expect(isPostRequester(post, currentUserId)).toBe(false);
});
it('returns false when llm_requester_user_id is missing', () => {
const post = TestHelper.fakePost({
props: {
some_other_prop: 'value',
},
});
expect(isPostRequester(post, currentUserId)).toBe(false);
});
});
describe('with PostModel objects', () => {
it('returns true when llm_requester_user_id matches current user ID', () => {
const postModel = TestHelper.fakePostModel({
props: {
llm_requester_user_id: currentUserId,
},
});
expect(isPostRequester(postModel, currentUserId)).toBe(true);
});
it('returns false when llm_requester_user_id does not match', () => {
const postModel = TestHelper.fakePostModel({
props: {
llm_requester_user_id: 'different_user',
},
});
expect(isPostRequester(postModel, currentUserId)).toBe(false);
});
it('returns false when props is empty object', () => {
const postModel = TestHelper.fakePostModel({
props: {},
});
expect(isPostRequester(postModel, currentUserId)).toBe(false);
});
});
describe('error handling', () => {
it('handles exceptions gracefully and returns false', () => {
// Create a post object that throws when accessing props
const faultyPost = {
get props() {
throw new Error('Access denied');
},
} as any;
expect(isPostRequester(faultyPost, currentUserId)).toBe(false);
});
});
});

View file

@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AGENT_POST_TYPES} from '@agents/constants';
import type PostModel from '@typings/database/models/servers/post';
/**
* Check if a post is an agent post
*/
export function isAgentPost(post: PostModel | Post): boolean {
return post.type === AGENT_POST_TYPES.LLMBOT ||
post.type === AGENT_POST_TYPES.LLM_POSTBACK;
}
/**
* Check if the current user is the requester of an agent post
* @param post The agent post
* @param currentUserId The current user ID
* @returns true if current user is the requester
*/
export function isPostRequester(post: PostModel | Post, currentUserId: string): boolean {
try {
const props = post.props as Record<string, unknown>;
return props?.llm_requester_user_id === currentUserId;
} catch {
return false;
}
}

View file

@ -276,3 +276,8 @@ export function safeDecodeURIComponent(v: string) {
return v;
}
}
export function getUrlDomain(url: string): string {
const parsed = urlParse(url);
return parsed.hostname || url;
}

View file

@ -13,6 +13,22 @@
"account.logout_from": "Log out of {serverName}",
"account.settings": "Settings",
"account.your_profile": "Your Profile",
"agents.citations.title": "Sources ({count})",
"agents.controls.regenerate": "Regenerate",
"agents.controls.stop": "Stop Generating",
"agents.generating": "Generating response...",
"agents.reasoning.thinking": "Thinking",
"agents.regenerate.cancel": "Cancel",
"agents.regenerate.confirm": "Regenerate",
"agents.regenerate.confirm_message": "This will clear the current response and generate a new one. Continue?",
"agents.regenerate.confirm_title": "Regenerate Response",
"agents.tool_call.approve": "Accept",
"agents.tool_call.pending_decisions": "{count, plural, =0 {All tools decided} one {# tool needs a decision} other {# tools need decisions}}",
"agents.tool_call.processing": "Processing...",
"agents.tool_call.reject": "Reject",
"agents.tool_call.response": "Response",
"agents.tool_call.status.rejected": "Rejected",
"agents.tool_call.submitting": "Submitting...",
"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.",
@ -1358,6 +1374,9 @@
"skintone_selector.tooltip.description": "You can now choose the skin tone you prefer to use for your emojis.",
"skintone_selector.tooltip.title": "Choose your default skin tone",
"smobile.search.recent_title": "Recent searches in {teamName}",
"snack.bar.agent.regenerate.error": "Failed to regenerate response",
"snack.bar.agent.stop.error": "Failed to stop generation",
"snack.bar.agent.tool.approval.error": "Failed to submit tool approval",
"snack.bar.channel.members.added": "{numMembers, number} {numMembers, plural, one {member} other {members}} added",
"snack.bar.code.copied": "Code copied to clipboard",
"snack.bar.default": "Error",

View file

@ -15,6 +15,7 @@ module.exports = {
root: ['.'],
alias: {
'@actions': './app/actions',
'@agents': './app/products/agents',
'@assets': './dist/assets/',
'@calls': './app/products/calls',
'@client': './app/client',

View file

@ -39,6 +39,7 @@
"baseUrl": ".",
"paths": {
"@actions/*": ["app/actions/*"],
"@agents/*": ["app/products/agents/*"],
"@assets/*": ["dist/assets/*"],
"@calls/*": ["app/products/calls/*"],
"@client/*": ["app/client/*"],

View file

@ -28,7 +28,9 @@ type PostType =
| 'system_auto_responder'
| 'custom_calls'
| 'custom_calls_recording'
| 'custom_run_update';
| 'custom_run_update'
| 'custom_llmbot'
| 'custom_llm_postback';
type PostEmbedType = 'image' | 'message_attachment' | 'opengraph' | 'permalink';