From 0c675ec2db69be4ca3a8fc89bf72d9b87dbb3793 Mon Sep 17 00:00:00 2001 From: Christopher Speller Date: Tue, 9 Dec 2025 03:56:12 -0800 Subject: [PATCH] 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 --- .gitignore | 3 + CLAUDE.md | 366 ++++++++++ app/actions/websocket/event.ts | 7 + app/actions/websocket/posts.ts | 8 +- app/client/rest/index.ts | 4 + app/components/post_list/post/post.tsx | 15 + app/constants/post.ts | 2 + app/constants/snack_bar.ts | 33 + app/constants/websocket.ts | 3 + .../remote/generation_controls.test.ts | 83 +++ .../actions/remote/generation_controls.ts | 46 ++ .../actions/remote/tool_approval.test.ts | 54 ++ .../agents/actions/remote/tool_approval.ts | 28 + .../agents/actions/websocket/index.test.ts | 83 +++ .../agents/actions/websocket/index.ts | 19 + app/products/agents/client/rest.ts | 41 ++ .../agents/components/agent_post/index.tsx | 203 ++++++ .../agent_post/streaming_indicator.tsx | 68 ++ .../components/citations_list/index.tsx | 185 +++++ .../agents/components/controls_bar/index.tsx | 136 ++++ .../components/reasoning_display/index.tsx | 135 ++++ .../reasoning_display/loading_spinner.tsx | 65 ++ .../components/tool_approval_set/index.tsx | 214 ++++++ .../agents/components/tool_card/index.tsx | 363 ++++++++++ app/products/agents/constants.ts | 33 + app/products/agents/store/index.ts | 4 + .../agents/store/streaming_store.test.ts | 634 ++++++++++++++++++ app/products/agents/store/streaming_store.ts | 294 ++++++++ app/products/agents/types/index.ts | 69 ++ app/products/agents/utils.test.ts | 134 ++++ app/products/agents/utils.ts | 29 + app/utils/url/index.ts | 5 + assets/base/i18n/en.json | 19 + babel.config.js | 1 + tsconfig.json | 1 + types/api/posts.d.ts | 4 +- 36 files changed, 3389 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md create mode 100644 app/products/agents/actions/remote/generation_controls.test.ts create mode 100644 app/products/agents/actions/remote/generation_controls.ts create mode 100644 app/products/agents/actions/remote/tool_approval.test.ts create mode 100644 app/products/agents/actions/remote/tool_approval.ts create mode 100644 app/products/agents/actions/websocket/index.test.ts create mode 100644 app/products/agents/actions/websocket/index.ts create mode 100644 app/products/agents/client/rest.ts create mode 100644 app/products/agents/components/agent_post/index.tsx create mode 100644 app/products/agents/components/agent_post/streaming_indicator.tsx create mode 100644 app/products/agents/components/citations_list/index.tsx create mode 100644 app/products/agents/components/controls_bar/index.tsx create mode 100644 app/products/agents/components/reasoning_display/index.tsx create mode 100644 app/products/agents/components/reasoning_display/loading_spinner.tsx create mode 100644 app/products/agents/components/tool_approval_set/index.tsx create mode 100644 app/products/agents/components/tool_card/index.tsx create mode 100644 app/products/agents/constants.ts create mode 100644 app/products/agents/store/index.ts create mode 100644 app/products/agents/store/streaming_store.test.ts create mode 100644 app/products/agents/store/streaming_store.ts create mode 100644 app/products/agents/types/index.ts create mode 100644 app/products/agents/utils.test.ts create mode 100644 app/products/agents/utils.ts diff --git a/.gitignore b/.gitignore index 07b10ce5a..f51348c65 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,6 @@ libraries/**/**/.build # Android sounds android/app/src/main/res/raw/* .aider* + +# Claude Code +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..eaa9fc125 --- /dev/null +++ b/CLAUDE.md @@ -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` 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 `` instead of ``, `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 && }`) +- **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` 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 diff --git a/app/actions/websocket/event.ts b/app/actions/websocket/event.ts index 396a347b3..762cfb5b7 100644 --- a/app/actions/websocket/event.ts +++ b/app/actions/websocket/event.ts @@ -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); } diff --git a/app/actions/websocket/posts.ts b/app/actions/websocket/posts.ts index 4254b7813..57b966aef 100644 --- a/app/actions/websocket/posts.ts +++ b/app/actions/websocket/posts.ts @@ -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) { diff --git a/app/client/rest/index.ts b/app/client/rest/index.ts index eaa2dc5dd..78814a008 100644 --- a/app/client/rest/index.ts +++ b/app/client/rest/index.ts @@ -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, diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index dae510e77..0029692ca 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -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 = ( + + ); } else { body = ( = { 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', diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index afafe8ce6..34ac8e2d4 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -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; diff --git a/app/products/agents/actions/remote/generation_controls.test.ts b/app/products/agents/actions/remote/generation_controls.test.ts new file mode 100644 index 000000000..9fb00a578 --- /dev/null +++ b/app/products/agents/actions/remote/generation_controls.test.ts @@ -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}); + }); +}); diff --git a/app/products/agents/actions/remote/generation_controls.ts b/app/products/agents/actions/remote/generation_controls.ts new file mode 100644 index 000000000..5beba9f5d --- /dev/null +++ b/app/products/agents/actions/remote/generation_controls.ts @@ -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)}; + } +} diff --git a/app/products/agents/actions/remote/tool_approval.test.ts b/app/products/agents/actions/remote/tool_approval.test.ts new file mode 100644 index 000000000..d66932e67 --- /dev/null +++ b/app/products/agents/actions/remote/tool_approval.test.ts @@ -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}); + }); +}); diff --git a/app/products/agents/actions/remote/tool_approval.ts b/app/products/agents/actions/remote/tool_approval.ts new file mode 100644 index 000000000..e308b84a6 --- /dev/null +++ b/app/products/agents/actions/remote/tool_approval.ts @@ -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)}; + } +} diff --git a/app/products/agents/actions/websocket/index.test.ts b/app/products/agents/actions/websocket/index.test.ts new file mode 100644 index 000000000..750a46735 --- /dev/null +++ b/app/products/agents/actions/websocket/index.test.ts @@ -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 = { + 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); + + 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); + + expect(streamingStore.handleWebSocketMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/app/products/agents/actions/websocket/index.ts b/app/products/agents/actions/websocket/index.ts new file mode 100644 index 000000000..451b0264b --- /dev/null +++ b/app/products/agents/actions/websocket/index.ts @@ -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): void { + if (!msg.data) { + return; + } + + // Delegate to the streaming store + streamingStore.handleWebSocketMessage(msg.data); +} diff --git a/app/products/agents/client/rest.ts b/app/products/agents/client/rest.ts new file mode 100644 index 000000000..1747ddb77 --- /dev/null +++ b/app/products/agents/client/rest.ts @@ -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; + regenerateResponse: (postId: string) => Promise; + submitToolApproval: (postId: string, acceptedToolIds: string[]) => Promise; +} + +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; diff --git a/app/products/agents/components/agent_post/index.tsx b/app/products/agents/components/agent_post/index.tsx new file mode 100644 index 000000000..cec12b02f --- /dev/null +++ b/app/products/agents/components/agent_post/index.tsx @@ -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; + 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; + 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; + 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 ( + + {showReasoning && ( + + )} + {isPrecontent ? ( + + + + + ) : ( + + {displayMessage ? ( + + ) : null} + {isGenerating && !isPrecontent && ( + + )} + + )} + {toolCalls.length > 0 && ( + + )} + {annotations.length > 0 && ( + + )} + {(showStopButton || showRegenerateButton) && ( + + )} + + ); +}; + +export default AgentPost; diff --git a/app/products/agents/components/agent_post/streaming_indicator.tsx b/app/products/agents/components/agent_post/streaming_indicator.tsx new file mode 100644 index 000000000..c6a52b360 --- /dev/null +++ b/app/products/agents/components/agent_post/streaming_indicator.tsx @@ -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 ( + + + + ); +}; + +export default StreamingIndicator; diff --git a/app/products/agents/components/citations_list/index.tsx b/app/products/agents/components/citations_list/index.tsx new file mode 100644 index 000000000..137b1489a --- /dev/null +++ b/app/products/agents/components/citations_list/index.tsx @@ -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 ( + + + + + + + + + + {isExpanded && ( + + {annotations.map((annotation, idx) => ( + handleCitationPress(annotation.url)} + style={styles.citationItem} + testID={`citations.list.item.${annotation.index}`} + > + + + + + + {annotation.title || getUrlDomain(annotation.url)} + + + {getUrlDomain(annotation.url)} + + + + + ))} + + )} + + ); +}; + +export default CitationsList; diff --git a/app/products/agents/components/controls_bar/index.tsx b/app/products/agents/components/controls_bar/index.tsx new file mode 100644 index 000000000..1cd9e1701 --- /dev/null +++ b/app/products/agents/components/controls_bar/index.tsx @@ -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 ( + + {showStopButton && ( + + + + + )} + {showRegenerateButton && ( + + + + + )} + + ); +}; + +export default ControlsBar; diff --git a/app/products/agents/components/reasoning_display/index.tsx b/app/products/agents/components/reasoning_display/index.tsx new file mode 100644 index 000000000..f206e2561 --- /dev/null +++ b/app/products/agents/components/reasoning_display/index.tsx @@ -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 ( + + + + + + {isReasoningLoading && ( + + )} + + + {isExpanded && reasoningSummary ? ( + + + + {reasoningSummary} + + + + ) : null} + + ); +}; + +export default ReasoningDisplay; diff --git a/app/products/agents/components/reasoning_display/loading_spinner.tsx b/app/products/agents/components/reasoning_display/loading_spinner.tsx new file mode 100644 index 000000000..a3a03a646 --- /dev/null +++ b/app/products/agents/components/reasoning_display/loading_spinner.tsx @@ -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 ( + + + + ); +}; + +export default LoadingSpinner; diff --git a/app/products/agents/components/tool_approval_set/index.tsx b/app/products/agents/components/tool_approval_set/index.tsx new file mode 100644 index 000000000..fb99fa70d --- /dev/null +++ b/app/products/agents/components/tool_approval_set/index.tsx @@ -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>({}); + const [toolDecisions, setToolDecisions] = useState({}); + + // 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 ( + + {pendingToolCalls.map((tool) => ( + + ))} + + {processedToolCalls.map((tool) => ( + + ))} + + {/* Only show status bar for multiple pending tools */} + {pendingToolCalls.length > 1 && isSubmitting && ( + + + + + )} + + {/* Only show status counter for multiple pending tools that haven't been submitted yet */} + {pendingToolCalls.length > 1 && undecidedCount > 0 && !isSubmitting && ( + + + + )} + + ); +}; + +export default ToolApprovalSet; diff --git a/app/products/agents/components/tool_card/index.tsx b/app/products/agents/components/tool_card/index.tsx new file mode 100644 index 000000000..43e79ba78 --- /dev/null +++ b/app/products/agents/components/tool_card/index.tsx @@ -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 ( + + ); + } + + if (isSuccess) { + return ( + + ); + } + + if (isError) { + return ( + + ); + } + + if (isRejected) { + return ( + + ); + } + + return null; + }; + + return ( + + + + + + {getStatusIcon()} + + {displayName} + + + + {!isCollapsed && ( + + + + + + {(isSuccess || isError) && resultMarkdown && ( + <> + + {isSuccess && ( + + )} + {isError && ( + + )} + + + + + + + )} + + {isRejected && ( + + + + + )} + + )} + + {isPending && !hasLocalDecision && isProcessing && ( + + + + + )} + + {isPending && !hasLocalDecision && !isProcessing && ( + + + + + + + + + )} + + ); +}; + +export default ToolCard; diff --git a/app/products/agents/constants.ts b/app/products/agents/constants.ts new file mode 100644 index 000000000..3ad66f30f --- /dev/null +++ b/app/products/agents/constants.ts @@ -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; diff --git a/app/products/agents/store/index.ts b/app/products/agents/store/index.ts new file mode 100644 index 000000000..de963ca88 --- /dev/null +++ b/app/products/agents/store/index.ts @@ -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'; diff --git a/app/products/agents/store/streaming_store.test.ts b/app/products/agents/store/streaming_store.test.ts new file mode 100644 index 000000000..3e3cfe594 --- /dev/null +++ b/app/products/agents/store/streaming_store.test.ts @@ -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 = []; + + 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 = []; + 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 = []; + 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 = []; + 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); + }); + }); +}); diff --git a/app/products/agents/store/streaming_store.ts b/app/products/agents/store/streaming_store.ts new file mode 100644 index 000000000..caf974c3f --- /dev/null +++ b/app/products/agents/store/streaming_store.ts @@ -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> = new Map(); + + /** + * Get or create a BehaviorSubject for a post + */ + private getSubject(postId: string): BehaviorSubject { + let subject = this.streamingSubjects.get(postId); + if (!subject) { + subject = new BehaviorSubject(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 { + 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( + () => streamingStore.getStreamingState(postId), + ); + + useEffect(() => { + const subscription = streamingStore.observeStreamingState(postId).subscribe(setState); + return () => subscription.unsubscribe(); + }, [postId]); + + return state; +} + +export default streamingStore; diff --git a/app/products/agents/types/index.ts b/app/products/agents/types/index.ts new file mode 100644 index 000000000..49c214753 --- /dev/null +++ b/app/products/agents/types/index.ts @@ -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 +} + diff --git a/app/products/agents/utils.test.ts b/app/products/agents/utils.test.ts new file mode 100644 index 000000000..f20c29c20 --- /dev/null +++ b/app/products/agents/utils.test.ts @@ -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); + }); + }); +}); diff --git a/app/products/agents/utils.ts b/app/products/agents/utils.ts new file mode 100644 index 000000000..03919617c --- /dev/null +++ b/app/products/agents/utils.ts @@ -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; + return props?.llm_requester_user_id === currentUserId; + } catch { + return false; + } +} diff --git a/app/utils/url/index.ts b/app/utils/url/index.ts index 018ee6b8d..62250b4b8 100644 --- a/app/utils/url/index.ts +++ b/app/utils/url/index.ts @@ -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; +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 050189ee2..8b9d69755 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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", diff --git a/babel.config.js b/babel.config.js index 539a28ff8..ca44678d1 100644 --- a/babel.config.js +++ b/babel.config.js @@ -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', diff --git a/tsconfig.json b/tsconfig.json index 0e68eec35..751a26a48 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,6 +39,7 @@ "baseUrl": ".", "paths": { "@actions/*": ["app/actions/*"], + "@agents/*": ["app/products/agents/*"], "@assets/*": ["dist/assets/*"], "@calls/*": ["app/products/calls/*"], "@client/*": ["app/client/*"], diff --git a/types/api/posts.d.ts b/types/api/posts.d.ts index b7bfad4b2..541808df5 100644 --- a/types/api/posts.d.ts +++ b/types/api/posts.d.ts @@ -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';