[MM-66359] Recaps MVP (#34337)

* initial commit for POC of Plugin Bridge

* Updates

* POC for plugin bridge

* Updates from collaboration

* Fixes

* Refactor Plugin Bridge to use HTTP/REST instead of RPC

- Remove ExecuteBridgeCall hook and Context.SourcePluginId
- Implement HTTP-based bridge using existing PluginHTTP infrastructure
- Add CallPlugin API method with endpoint parameter instead of method name
- Update CallPluginBridge to construct HTTP POST requests
- Add proper headers: Mattermost-User-Id, Mattermost-Plugin-ID
- Use 'com.mattermost.server' as plugin ID for core server calls
- Update ai.go to use REST endpoint /inter-plugin/v1/completion
- Add comprehensive spec documentation in server/spec.md
- Add MIGRATION_GUIDE.md for plugin developers
- Fix 401/404 issues by setting correct headers and URL paths

* Improve Plugin Bridge security and architecture

- Create ServeInternalPluginRequest for internal plugin calls (core + plugin-to-plugin)
- Move header-setting logic from CallPluginBridge to ServeInternalPluginRequest
- Improve separation of concerns: business logic vs HTTP transport
- Add security documentation explaining header protection

Security Improvements:
- ServeInternalPluginRequest is NOT exposed as HTTP route (internal only)
- Headers (Mattermost-User-Id, Mattermost-Plugin-ID) are set by trusted server code
- External requests cannot spoof these headers (stripped by servePluginRequest)
- Core calls use 'com.mattermost.server' as plugin ID for authorization
- Plugin-to-plugin calls use real plugin ID (enforced by server)

Backward Compatibility:
- Keep ServeInterPluginRequest for existing API.PluginHTTP callers (deprecated)
- All tests pass

Docs:
- Update spec.md with security model explanation
- Update MIGRATION_GUIDE.md with correct header usage examples

* Space

* cursor please stop creating markdown files

* Fix style

* Fix i18n, linter

* REMOVE MARKDOWN

* Remove CallPlugin method from plugin API interface

Per review feedback, this method is no longer needed.

Co-authored-by: Nick Misasi <nickmisasi@users.noreply.github.com>

* Remove CallPlugin method implementation from PluginAPI

Co-authored-by: Nick Misasi <nickmisasi@users.noreply.github.com>

* fixes

* Add AI OpenAPI spec

* fix openapi spec

* Use agents client (#34225)

* Use agents client

* Remove default agent

* Fixes

* fix: modify system prompts to ensure JSON is being returned

* Base implementation for recaps working

* small fixes

* Adjustments

* remove webapp changes

* Add feature flags for rewrites and ai bridge, clean up

* Remove comments that aren't helpful

* Fix i18n

* Remove rewrites

* Fix tests

* Fix i18n

* adjust i18n again

* Add back translations

* Remove leftover mock code

* remove model file

* Changes from PR review

* Make the real substitutions

* Include a basic invokation of the client with noop to ensure build works

* more fix

* Remove unneeded change

* Updates from review

* Fixes

* Remove some logic from rewrites to clean up branch

* Use v1.5.0 of agents plugin

* A bunch more additions for general UX flow

* Add missing files

* Add mocks

* Fixes for vet-api, i18n, build, types, etc

* One more linter fix

* Fix i18n and some tests

* Refactors and cleanup in backend code

* remove rogue markdown file

* fixes after refactors from backend

* Add back renamed files, and add tests

* More self code review

* More fixes

* More refactors

* Fix call stack exceeded bug

* Include read messages if there are no unreads

* Fix test failure: use correct error message key for recap permission denied

The getRecapAndCheckOwnership function was using strings.ToLower(callerName)
to generate error keys, which caused 'GetRecap' to become 'getrecap' instead
of the expected 'get'. Changed to use the correct static key that matches
the en.json localization file.

Fixes TestGetRecap/get_recap_by_non-owner test failure.

Co-authored-by: Nick Misasi <nickmisasi@users.noreply.github.com>

* Consolidate permission errors down to a single string

* Fixes for i18n, worktrees making this difficult

* Fix i18n

* Fix i18n once and for all (for real) (final)

* Fix duplicate getAgents method in client4.ts

* Remove duplicate ai state from initial_state.ts

* Fix types

* Fix tests

* Fix return type of GetAgents and GetServices

* Add tests for recaps components

* Fix types

* Update i18n

* Fixes

* Fixes

* More cleanup

* Revert random file

* Use undefined

* fix linter

* Address feedback

* Missed a git add

* Fixes

* Fix i18n

* Remove fallback

* Fixes for PR

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Nick Misasi <nickmisasi@users.noreply.github.com>
Co-authored-by: Christopher Speller <crspeller@gmail.com>
Co-authored-by: Felipe Martin <me@fmartingr.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Nick Misasi 2026-01-13 11:59:22 -05:00 committed by GitHub
parent 9e1d4c2072
commit 8e4cadbc88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 7503 additions and 6 deletions

View file

@ -20,6 +20,8 @@ build-v4: node_modules playbooks
@cat $(V4_SRC)/posts.yaml >> $(V4_YAML)
@cat $(V4_SRC)/preferences.yaml >> $(V4_YAML)
@cat $(V4_SRC)/files.yaml >> $(V4_YAML)
@cat $(V4_SRC)/recaps.yaml >> $(V4_YAML)
@cat $(V4_SRC)/ai.yaml >> $(V4_YAML)
@cat $(V4_SRC)/uploads.yaml >> $(V4_YAML)
@cat $(V4_SRC)/jobs.yaml >> $(V4_YAML)
@cat $(V4_SRC)/system.yaml >> $(V4_YAML)

54
api/v4/source/ai.yaml Normal file
View file

@ -0,0 +1,54 @@
/api/v4/ai/agents:
get:
tags:
- ai
summary: Get available AI agents
description: >
Retrieve all available AI agents from the AI plugin's bridge API.
If a user ID is provided, only agents accessible to that user are returned.
##### Permissions
Must be authenticated.
__Minimum server version__: 11.2
operationId: GetAIAgents
responses:
"200":
description: AI agents retrieved successfully
content:
application/json:
schema:
$ref: "#/components/schemas/AgentsResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/InternalServerError"
/api/v4/ai/services:
get:
tags:
- ai
summary: Get available AI services
description: >
Retrieve all available AI services from the AI plugin's bridge API.
If a user ID is provided, only services accessible to that user
(via their permitted bots) are returned.
##### Permissions
Must be authenticated.
__Minimum server version__: 11.2
operationId: GetAIServices
responses:
"200":
description: AI services retrieved successfully
content:
application/json:
schema:
$ref: "#/components/schemas/ServicesResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/InternalServerError"

View file

@ -4633,6 +4633,83 @@ components:
active:
type: boolean
description: The active status of the policy.
Recap:
type: object
properties:
id:
type: string
description: Unique identifier for the recap
user_id:
type: string
description: ID of the user who created the recap
title:
type: string
description: AI-generated title for the recap (max 5 words)
create_at:
type: integer
format: int64
description: The time in milliseconds the recap was created
update_at:
type: integer
format: int64
description: The time in milliseconds the recap was last updated
delete_at:
type: integer
format: int64
description: The time in milliseconds the recap was deleted
read_at:
type: integer
format: int64
description: The time in milliseconds the recap was marked as read
total_message_count:
type: integer
description: Total number of messages summarized across all channels
status:
type: string
enum: [pending, processing, completed, failed]
description: Current status of the recap job
bot_id:
type: string
description: ID of the AI agent/bot used to generate this recap
channels:
type: array
items:
$ref: "#/components/schemas/RecapChannel"
description: List of channel summaries included in this recap
RecapChannel:
type: object
properties:
id:
type: string
description: Unique identifier for the recap channel
recap_id:
type: string
description: ID of the parent recap
channel_id:
type: string
description: ID of the channel that was summarized
channel_name:
type: string
description: Display name of the channel
highlights:
type: array
items:
type: string
description: Key discussion points and important information from the channel
action_items:
type: array
items:
type: string
description: Tasks, todos, and action items mentioned in the channel
source_post_ids:
type: array
items:
type: string
description: IDs of the posts used to generate this summary
create_at:
type: integer
format: int64
description: The time in milliseconds the recap channel was created
externalDocs:
description: Find out more about Mattermost
url: 'https://about.mattermost.com'

View file

@ -462,8 +462,10 @@ tags:
description: Endpoints related to metrics, including the Client Performance Monitoring feature.
- name: audit_logs
description: Endpoints for managing audit log certificates and configuration.
- name: ai
description: Endpoints for interacting with AI agents and services.
- name: recaps
description: Endpoints for creating and managing AI-powered channel recaps that summarize unread messages.
- name: agents
description: Endpoints for interacting with AI agents and LLM services.
servers:
- url: "{your-mattermost-url}"
variables:

240
api/v4/source/recaps.yaml Normal file
View file

@ -0,0 +1,240 @@
"/api/v4/recaps":
post:
tags:
- recaps
- ai
summary: Create a channel recap
description: >
Create a new AI-powered recap for the specified channels. The recap will
summarize unread messages in the selected channels, extracting highlights
and action items. This creates a background job that processes the recap
asynchronously. The recap is created for the authenticated user.
##### Permissions
Must be authenticated. User must be a member of all specified channels.
__Minimum server version__: 11.2
operationId: CreateRecap
requestBody:
content:
application/json:
schema:
type: object
required:
- channel_ids
- title
- agent_id
properties:
title:
type: string
description: Title for the recap
channel_ids:
type: array
items:
type: string
description: List of channel IDs to include in the recap
minItems: 1
agent_id:
type: string
description: ID of the AI agent to use for generating the recap
description: Recap creation request
required: true
responses:
"201":
description: Recap creation successful. The recap will be processed asynchronously.
content:
application/json:
schema:
$ref: "#/components/schemas/Recap"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
get:
tags:
- recaps
- ai
summary: Get current user's recaps
description: >
Get a paginated list of recaps created by the authenticated user.
##### Permissions
Must be authenticated.
__Minimum server version__: 11.2
operationId: GetRecapsForUser
parameters:
- name: page
in: query
description: The page to select.
schema:
type: integer
default: 0
- name: per_page
in: query
description: The number of recaps per page.
schema:
type: integer
default: 60
responses:
"200":
description: Recaps retrieval successful
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Recap"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"/api/v4/recaps/{recap_id}":
get:
tags:
- recaps
- ai
summary: Get a specific recap
description: >
Get a recap by its ID, including all channel summaries. Only the authenticated
user who created the recap can retrieve it.
##### Permissions
Must be authenticated. Can only retrieve recaps created by the current user.
__Minimum server version__: 11.2
operationId: GetRecap
parameters:
- name: recap_id
in: path
description: Recap GUID
required: true
schema:
type: string
responses:
"200":
description: Recap retrieval successful
content:
application/json:
schema:
$ref: "#/components/schemas/Recap"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
delete:
tags:
- recaps
- ai
summary: Delete a recap
description: >
Delete a recap by its ID. Only the authenticated user who created the recap
can delete it.
##### Permissions
Must be authenticated. Can only delete recaps created by the current user.
__Minimum server version__: 11.2
operationId: DeleteRecap
parameters:
- name: recap_id
in: path
description: Recap GUID
required: true
schema:
type: string
responses:
"200":
description: Recap deletion successful
content:
application/json:
schema:
$ref: "#/components/schemas/StatusOK"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"/api/v4/recaps/{recap_id}/read":
post:
tags:
- recaps
- ai
summary: Mark a recap as read
description: >
Mark a recap as read by the authenticated user. This updates the recap's
read status and timestamp.
##### Permissions
Must be authenticated. Can only mark recaps created by the current user as read.
__Minimum server version__: 11.2
operationId: MarkRecapAsRead
parameters:
- name: recap_id
in: path
description: Recap GUID
required: true
schema:
type: string
responses:
"200":
description: Recap marked as read successfully
content:
application/json:
schema:
$ref: "#/components/schemas/Recap"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"/api/v4/recaps/{recap_id}/regenerate":
post:
tags:
- recaps
- ai
summary: Regenerate a recap
description: >
Regenerate a recap by its ID. This creates a new background job to
regenerate the AI-powered recap with the latest messages from the
specified channels.
##### Permissions
Must be authenticated. Can only regenerate recaps created by the current user.
__Minimum server version__: 11.2
operationId: RegenerateRecap
parameters:
- name: recap_id
in: path
description: Recap GUID
required: true
schema:
type: string
responses:
"200":
description: Recap regeneration initiated successfully
content:
application/json:
schema:
$ref: "#/components/schemas/Recap"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"

View file

@ -101,6 +101,8 @@ type Routes struct {
Jobs *mux.Router // 'api/v4/jobs'
Recaps *mux.Router // 'api/v4/recaps'
Preferences *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/preferences'
License *mux.Router // 'api/v4/license'
@ -256,6 +258,7 @@ func Init(srv *app.Server) (*API, error) {
api.BaseRoutes.Public = api.BaseRoutes.APIRoot.PathPrefix("/public").Subrouter()
api.BaseRoutes.Reactions = api.BaseRoutes.APIRoot.PathPrefix("/reactions").Subrouter()
api.BaseRoutes.Jobs = api.BaseRoutes.APIRoot.PathPrefix("/jobs").Subrouter()
api.BaseRoutes.Recaps = api.BaseRoutes.APIRoot.PathPrefix("/recaps").Subrouter()
api.BaseRoutes.Elasticsearch = api.BaseRoutes.APIRoot.PathPrefix("/elasticsearch").Subrouter()
api.BaseRoutes.DataRetention = api.BaseRoutes.APIRoot.PathPrefix("/data_retention").Subrouter()
@ -337,6 +340,7 @@ func Init(srv *app.Server) (*API, error) {
api.InitDataRetention()
api.InitBrand()
api.InitJob()
api.InitRecap()
api.InitCommand()
api.InitStatus()
api.InitWebSocket()

View file

@ -0,0 +1,210 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitRecap() {
api.BaseRoutes.Recaps.Handle("", api.APISessionRequired(createRecap)).Methods(http.MethodPost)
api.BaseRoutes.Recaps.Handle("", api.APISessionRequired(getRecaps)).Methods(http.MethodGet)
api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}", api.APISessionRequired(getRecap)).Methods(http.MethodGet)
api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}/read", api.APISessionRequired(markRecapAsRead)).Methods(http.MethodPost)
api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}/regenerate", api.APISessionRequired(regenerateRecap)).Methods(http.MethodPost)
api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}", api.APISessionRequired(deleteRecap)).Methods(http.MethodDelete)
}
func requireRecapsEnabled(c *Context) {
if !c.App.Config().FeatureFlags.EnableAIRecaps {
c.Err = model.NewAppError("requireRecapsEnabled", "api.recap.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
}
func createRecap(c *Context, w http.ResponseWriter, r *http.Request) {
requireRecapsEnabled(c)
if c.Err != nil {
return
}
var req model.CreateRecapRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.SetInvalidParamWithErr("body", err)
return
}
if len(req.ChannelIds) == 0 {
c.SetInvalidParam("channel_ids")
return
}
if req.Title == "" {
c.SetInvalidParam("title")
return
}
if req.AgentID == "" {
c.SetInvalidParam("agent_id")
return
}
recap, err := c.App.CreateRecap(c.AppContext, req.Title, req.ChannelIds, req.AgentID)
if err != nil {
c.Err = err
return
}
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(recap); err != nil {
c.Logger.Warn("Error encoding response", mlog.Err(err))
}
}
func getRecap(c *Context, w http.ResponseWriter, r *http.Request) {
requireRecapsEnabled(c)
if c.Err != nil {
return
}
c.RequireRecapId()
if c.Err != nil {
return
}
recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId)
if err != nil {
c.Err = err
return
}
if recap.UserId != c.AppContext.Session().UserId {
c.Err = model.NewAppError("getRecap", "api.recap.permission_denied", nil, "", http.StatusForbidden)
return
}
if err := json.NewEncoder(w).Encode(recap); err != nil {
c.Logger.Warn("Error encoding response", mlog.Err(err))
}
}
func getRecaps(c *Context, w http.ResponseWriter, r *http.Request) {
requireRecapsEnabled(c)
if c.Err != nil {
return
}
recaps, err := c.App.GetRecapsForUser(c.AppContext, c.Params.Page, c.Params.PerPage)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(recaps); err != nil {
c.Logger.Warn("Error encoding response", mlog.Err(err))
}
}
func markRecapAsRead(c *Context, w http.ResponseWriter, r *http.Request) {
requireRecapsEnabled(c)
if c.Err != nil {
return
}
c.RequireRecapId()
if c.Err != nil {
return
}
// Check permissions
recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId)
if err != nil {
c.Err = err
return
}
if recap.UserId != c.AppContext.Session().UserId {
c.Err = model.NewAppError("markRecapAsRead", "api.recap.permission_denied", nil, "", http.StatusForbidden)
return
}
updatedRecap, err := c.App.MarkRecapAsRead(c.AppContext, recap)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(updatedRecap); err != nil {
c.Logger.Warn("Error encoding response", mlog.Err(err))
}
}
func regenerateRecap(c *Context, w http.ResponseWriter, r *http.Request) {
requireRecapsEnabled(c)
if c.Err != nil {
return
}
c.RequireRecapId()
if c.Err != nil {
return
}
// Check permissions
recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId)
if err != nil {
c.Err = err
return
}
if recap.UserId != c.AppContext.Session().UserId {
c.Err = model.NewAppError("regenerateRecap", "api.recap.permission_denied", nil, "", http.StatusForbidden)
return
}
updatedRecap, err := c.App.RegenerateRecap(c.AppContext, c.AppContext.Session().UserId, recap)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(updatedRecap); err != nil {
c.Logger.Warn("Error encoding response", mlog.Err(err))
}
}
func deleteRecap(c *Context, w http.ResponseWriter, r *http.Request) {
requireRecapsEnabled(c)
if c.Err != nil {
return
}
c.RequireRecapId()
if c.Err != nil {
return
}
// Check permissions
recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId)
if err != nil {
c.Err = err
return
}
if recap.UserId != c.AppContext.Session().UserId {
c.Err = model.NewAppError("deleteRecap", "api.recap.permission_denied", nil, "", http.StatusForbidden)
return
}
if err := c.App.DeleteRecap(c.AppContext, c.Params.RecapId); err != nil {
c.Err = err
return
}
ReturnStatusOK(w)
}

View file

@ -4,6 +4,8 @@
package app
import (
"net/http"
"github.com/blang/semver/v4"
agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient"
@ -93,7 +95,7 @@ func (a *App) GetAgents(rctx request.CTX, userID string) ([]agentclient.BridgeAg
mlog.Err(err),
mlog.String("user_id", userID),
)
return nil, model.NewAppError("GetAgents", "app.agents.get_agents.bridge_call_failed", nil, err.Error(), 500)
return nil, model.NewAppError("GetAgents", "app.agents.get_agents.bridge_call_failed", nil, err.Error(), http.StatusInternalServerError)
}
return agents, nil
@ -119,7 +121,7 @@ func (a *App) GetLLMServices(rctx request.CTX, userID string) ([]agentclient.Bri
mlog.Err(err),
mlog.String("user_id", userID),
)
return nil, model.NewAppError("GetLLMServices", "app.agents.get_services.bridge_call_failed", nil, err.Error(), 500)
return nil, model.NewAppError("GetLLMServices", "app.agents.get_services.bridge_call_failed", nil, err.Error(), http.StatusInternalServerError)
}
return services, nil

View file

@ -0,0 +1,301 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// CreateRecap creates a new recap job for the specified channels
func (a *App) CreateRecap(rctx request.CTX, title string, channelIDs []string, agentID string) (*model.Recap, *model.AppError) {
userID := rctx.Session().UserId
// Validate user is member of all channels
for _, channelID := range channelIDs {
if !a.HasPermissionToChannel(rctx, userID, channelID, model.PermissionReadChannel) {
return nil, model.NewAppError("CreateRecap", "app.recap.permission_denied", nil, "", http.StatusForbidden)
}
}
timeNow := model.GetMillis()
// Create recap record
recap := &model.Recap{
Id: model.NewId(),
UserId: userID,
Title: title,
CreateAt: timeNow,
UpdateAt: timeNow,
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 0,
Status: model.RecapStatusPending,
BotID: agentID,
}
savedRecap, err := a.Srv().Store().Recap().SaveRecap(recap)
if err != nil {
return nil, model.NewAppError("CreateRecap", "app.recap.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Create background job
jobData := map[string]string{
"recap_id": recap.Id,
"user_id": userID,
"channel_ids": strings.Join(channelIDs, ","),
"agent_id": agentID,
}
_, jobErr := a.CreateJob(rctx, &model.Job{
Type: model.JobTypeRecap,
Data: jobData,
})
if jobErr != nil {
return nil, jobErr
}
return savedRecap, nil
}
// GetRecap retrieves a recap by ID
func (a *App) GetRecap(rctx request.CTX, recapID string) (*model.Recap, *model.AppError) {
recap, err := a.Srv().Store().Recap().GetRecap(recapID)
if err != nil {
return nil, model.NewAppError("GetRecap", "app.recap.get.app_error", nil, "", http.StatusNotFound).Wrap(err)
}
// Load channels
channels, err := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID)
if err != nil {
return nil, model.NewAppError("GetRecap", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
recap.Channels = channels
return recap, nil
}
// GetRecapsForUser retrieves all recaps for a user
func (a *App) GetRecapsForUser(rctx request.CTX, page, perPage int) ([]*model.Recap, *model.AppError) {
userID := rctx.Session().UserId
recaps, err := a.Srv().Store().Recap().GetRecapsForUser(userID, page, perPage)
if err != nil {
return nil, model.NewAppError("GetRecapsForUser", "app.recap.list.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return recaps, nil
}
// MarkRecapAsRead marks a recap as read
func (a *App) MarkRecapAsRead(rctx request.CTX, recap *model.Recap) (*model.Recap, *model.AppError) {
// Mark as read
if markErr := a.Srv().Store().Recap().MarkRecapAsRead(recap.Id); markErr != nil {
return nil, model.NewAppError("MarkRecapAsRead", "app.recap.mark_read.app_error", nil, "", http.StatusInternalServerError).Wrap(markErr)
}
// Update the passed recap with read timestamp
recap.ReadAt = model.GetMillis()
recap.UpdateAt = recap.ReadAt
// Load channels if not already loaded
if recap.Channels == nil {
channels, err := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recap.Id)
if err != nil {
return nil, model.NewAppError("MarkRecapAsRead", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
recap.Channels = channels
}
return recap, nil
}
// RegenerateRecap regenerates an existing recap
func (a *App) RegenerateRecap(rctx request.CTX, userID string, recap *model.Recap) (*model.Recap, *model.AppError) {
recapID := recap.Id
// Get existing recap channels to extract channel IDs
channels, err := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID)
if err != nil {
return nil, model.NewAppError("RegenerateRecap", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Extract channel IDs
channelIDs := make([]string, len(channels))
for i, channel := range channels {
channelIDs[i] = channel.ChannelId
}
// Delete existing recap channels
if deleteErr := a.Srv().Store().Recap().DeleteRecapChannels(recapID); deleteErr != nil {
return nil, model.NewAppError("RegenerateRecap", "app.recap.delete_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(deleteErr)
}
// Update recap status to pending and reset read status
recap.Status = model.RecapStatusPending
recap.ReadAt = 0
recap.UpdateAt = model.GetMillis()
recap.TotalMessageCount = 0
if _, updateErr := a.Srv().Store().Recap().UpdateRecap(recap); updateErr != nil {
return nil, model.NewAppError("RegenerateRecap", "app.recap.update.app_error", nil, "", http.StatusInternalServerError).Wrap(updateErr)
}
// Create new job with same parameters
jobData := map[string]string{
"recap_id": recapID,
"user_id": userID,
"channel_ids": strings.Join(channelIDs, ","),
"agent_id": recap.BotID,
}
_, jobErr := a.CreateJob(rctx, &model.Job{
Type: model.JobTypeRecap,
Data: jobData,
})
if jobErr != nil {
return nil, jobErr
}
// Return updated recap
updatedRecap, getErr := a.GetRecap(rctx, recapID)
if getErr != nil {
return nil, getErr
}
return updatedRecap, nil
}
// DeleteRecap deletes a recap (soft delete)
func (a *App) DeleteRecap(rctx request.CTX, recapID string) *model.AppError {
// Delete recap
if deleteErr := a.Srv().Store().Recap().DeleteRecap(recapID); deleteErr != nil {
return model.NewAppError("DeleteRecap", "app.recap.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(deleteErr)
}
return nil
}
// ProcessRecapChannel processes a single channel for a recap, fetching posts, summarizing them,
// and saving the recap channel record. Returns the number of messages processed.
func (a *App) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, agentID string) (*model.RecapChannelResult, *model.AppError) {
result := &model.RecapChannelResult{
ChannelID: channelID,
Success: false,
}
// Get channel info
channel, err := a.GetChannel(rctx, channelID)
if err != nil {
return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Get user's last viewed timestamp
lastViewedAt, lastViewedErr := a.Srv().Store().Channel().GetMemberLastViewedAt(rctx, channelID, userID)
if lastViewedErr != nil {
return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_last_viewed.app_error", nil, "", http.StatusInternalServerError).Wrap(lastViewedErr)
}
// Fetch posts for recap
posts, postsErr := a.fetchPostsForRecap(rctx, channelID, lastViewedAt, 100)
if postsErr != nil {
return result, postsErr
}
// No posts to summarize - return success with 0 messages
if len(posts) == 0 {
result.Success = true
return result, nil
}
// Get team info for permalink generation
team, teamErr := a.GetTeam(channel.TeamId)
if teamErr != nil {
return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_team.app_error", nil, "", http.StatusInternalServerError).Wrap(teamErr)
}
// Summarize posts
summary, err := a.SummarizePosts(rctx, userID, posts, channel.DisplayName, team.Name, agentID)
if err != nil {
return result, err
}
// Save recap channel
recapChannel := &model.RecapChannel{
Id: model.NewId(),
RecapId: recapID,
ChannelId: channelID,
ChannelName: channel.DisplayName,
Highlights: summary.Highlights,
ActionItems: summary.ActionItems,
SourcePostIds: extractPostIDs(posts),
CreateAt: model.GetMillis(),
}
if err := a.Srv().Store().Recap().SaveRecapChannel(recapChannel); err != nil {
return result, model.NewAppError("ProcessRecapChannel", "app.recap.save_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
result.MessageCount = len(posts)
result.Success = true
return result, nil
}
// fetchPostsForRecap fetches posts for a channel after the given timestamp and enriches them with user information
func (a *App) fetchPostsForRecap(rctx request.CTX, channelID string, lastViewedAt int64, limit int) ([]*model.Post, *model.AppError) {
// Get posts after lastViewedAt
options := model.GetPostsSinceOptions{
ChannelId: channelID,
Time: lastViewedAt,
}
postList, err := a.GetPostsSince(rctx, options)
if err != nil {
return nil, err
}
if len(postList.Posts) == 0 {
// If there are no unread posts, get the most recent 15 posts to include in the recap
postList, err = a.GetPosts(rctx, channelID, 0, 20)
if err != nil {
return nil, err
}
}
// Convert to slice and limit
posts := make([]*model.Post, 0, len(postList.Posts))
for _, postID := range postList.Order {
if post, ok := postList.Posts[postID]; ok {
posts = append(posts, post)
if len(posts) >= limit {
break
}
}
}
// Enrich with usernames
for _, post := range posts {
user, _ := a.GetUser(post.UserId)
if user != nil {
if post.Props == nil {
post.Props = make(model.StringInterface)
}
post.AddProp("username", user.Username)
}
}
return posts, nil
}
// extractPostIDs extracts post IDs from a slice of posts
func extractPostIDs(posts []*model.Post) []string {
ids := make([]string, len(posts))
for i, post := range posts {
ids[i] = post.Id
}
return ids
}

View file

@ -0,0 +1,307 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"os"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateRecap(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS")
th := Setup(t).InitBasic(t)
t.Run("create recap with valid channels", func(t *testing.T) {
channel2 := th.CreateChannel(t, th.BasicTeam)
channelIds := []string{th.BasicChannel.Id, channel2.Id}
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recap, err := th.App.CreateRecap(ctx, "My Test Recap", channelIds, "test-agent-id")
require.Nil(t, err)
require.NotNil(t, recap)
assert.Equal(t, th.BasicUser.Id, recap.UserId)
assert.Equal(t, model.RecapStatusPending, recap.Status)
assert.Equal(t, "My Test Recap", recap.Title)
})
t.Run("create recap with channel user is not member of", func(t *testing.T) {
// Create a private channel and add only BasicUser2
privateChannel := th.CreatePrivateChannel(t, th.BasicTeam)
// Remove BasicUser if they were added automatically
_ = th.App.RemoveUserFromChannel(th.Context, th.BasicUser.Id, "", privateChannel)
// Ensure BasicUser2 is a member instead
th.AddUserToChannel(t, th.BasicUser2, privateChannel)
// Try to create recap as BasicUser who is not a member
channelIds := []string{privateChannel.Id}
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recap, err := th.App.CreateRecap(ctx, "Test Recap", channelIds, "test-agent-id")
require.NotNil(t, err)
assert.Nil(t, recap)
assert.Equal(t, "app.recap.permission_denied", err.Id)
})
}
func TestGetRecap(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS")
th := Setup(t).InitBasic(t)
t.Run("get recap by owner", func(t *testing.T) {
recap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
}
_, err := th.App.Srv().Store().Recap().SaveRecap(recap)
require.NoError(t, err)
// Create recap channel
recapChannel := &model.RecapChannel{
Id: model.NewId(),
RecapId: recap.Id,
ChannelId: th.BasicChannel.Id,
ChannelName: th.BasicChannel.DisplayName,
Highlights: []string{"Test highlight"},
ActionItems: []string{"Test action"},
SourcePostIds: []string{model.NewId()},
CreateAt: model.GetMillis(),
}
err = th.App.Srv().Store().Recap().SaveRecapChannel(recapChannel)
require.NoError(t, err)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
retrievedRecap, appErr := th.App.GetRecap(ctx, recap.Id)
require.Nil(t, appErr)
require.NotNil(t, retrievedRecap)
assert.Equal(t, recap.Id, retrievedRecap.Id)
assert.Len(t, retrievedRecap.Channels, 1)
assert.Equal(t, recapChannel.ChannelName, retrievedRecap.Channels[0].ChannelName)
})
t.Run("get recap by non-owner", func(t *testing.T) {
recap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
}
_, err := th.App.Srv().Store().Recap().SaveRecap(recap)
require.NoError(t, err)
// Try to get as a different user - create context with BasicUser2's session
ctx := request.TestContext(t).WithSession(&model.Session{UserId: th.BasicUser2.Id})
retrievedRecap, appErr := th.App.GetRecap(ctx, recap.Id)
// Permissions are now checked in API layer, so App layer should return the recap
require.Nil(t, appErr)
require.NotNil(t, retrievedRecap)
assert.Equal(t, recap.Id, retrievedRecap.Id)
})
}
func TestGetRecapsForUser(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS")
th := Setup(t).InitBasic(t)
t.Run("get recaps for user", func(t *testing.T) {
// Create multiple recaps for the user
for range 5 {
recap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
}
_, err := th.App.Srv().Store().Recap().SaveRecap(recap)
require.NoError(t, err)
}
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recaps, err := th.App.GetRecapsForUser(ctx, 0, 10)
require.Nil(t, err)
assert.Len(t, recaps, 5)
})
t.Run("pagination works correctly", func(t *testing.T) {
userId := model.NewId()
// Create context with the test user's session
ctx := request.TestContext(t).WithSession(&model.Session{UserId: userId})
// Create 15 recaps
for range 15 {
recap := &model.Recap{
Id: model.NewId(),
UserId: userId,
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
}
_, err := th.App.Srv().Store().Recap().SaveRecap(recap)
require.NoError(t, err)
}
// Get first page
recapsPage1, err := th.App.GetRecapsForUser(ctx, 0, 10)
require.Nil(t, err)
assert.Len(t, recapsPage1, 10)
// Get second page
recapsPage2, err := th.App.GetRecapsForUser(ctx, 1, 10)
require.Nil(t, err)
assert.Len(t, recapsPage2, 5)
})
}
func TestMarkRecapAsRead(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS")
th := Setup(t).InitBasic(t)
t.Run("mark recap as read by owner", func(t *testing.T) {
recap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
}
savedRecap, err := th.App.Srv().Store().Recap().SaveRecap(recap)
require.NoError(t, err)
// Mark as read
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
updatedRecap, appErr := th.App.MarkRecapAsRead(ctx, savedRecap)
require.Nil(t, appErr)
require.NotNil(t, updatedRecap)
assert.Greater(t, updatedRecap.ReadAt, int64(0))
})
t.Run("mark recap as read by non-owner", func(t *testing.T) {
recap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
}
savedRecap, err := th.App.Srv().Store().Recap().SaveRecap(recap)
require.NoError(t, err)
// Try to mark as read as a different user - create context with BasicUser2's session
ctx := request.TestContext(t).WithSession(&model.Session{UserId: th.BasicUser2.Id})
updatedRecap, appErr := th.App.MarkRecapAsRead(ctx, savedRecap)
// Permissions are now checked in API layer, so App layer should allow it
require.Nil(t, appErr)
require.NotNil(t, updatedRecap)
assert.Greater(t, updatedRecap.ReadAt, int64(0))
})
}
func TestProcessRecapChannel(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS")
th := Setup(t).InitBasic(t)
t.Run("process empty channel", func(t *testing.T) {
// Ensure channel has no posts (it shouldn't in init)
channel := th.CreateChannel(t, th.BasicTeam)
// No posts added
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recapID := model.NewId()
agentID := "test-agent"
result, err := th.App.ProcessRecapChannel(ctx, recapID, channel.Id, th.BasicUser.Id, agentID)
require.Nil(t, err)
require.NotNil(t, result)
assert.True(t, result.Success)
assert.Equal(t, 0, result.MessageCount)
})
t.Run("process channel with posts", func(t *testing.T) {
// This test expects failure at SummarizePosts because we can't mock AI easily in integration test
channel := th.CreateChannel(t, th.BasicTeam)
th.CreatePost(t, channel)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recapID := model.NewId()
agentID := "test-agent"
result, err := th.App.ProcessRecapChannel(ctx, recapID, channel.Id, th.BasicUser.Id, agentID)
// It will fail at SummarizePosts agent call
require.NotNil(t, err)
assert.Equal(t, "app.ai.summarize.agent_call_failed", err.Id)
assert.False(t, result.Success)
})
}
func TestExtractPostIDs(t *testing.T) {
t.Run("extract post IDs from posts", func(t *testing.T) {
posts := []*model.Post{
{Id: "post1", Message: "test1"},
{Id: "post2", Message: "test2"},
{Id: "post3", Message: "test3"},
}
ids := extractPostIDs(posts)
assert.Len(t, ids, 3)
assert.Equal(t, "post1", ids[0])
assert.Equal(t, "post2", ids[1])
assert.Equal(t, "post3", ids[2])
})
t.Run("extract from empty posts", func(t *testing.T) {
posts := []*model.Post{}
ids := extractPostIDs(posts)
assert.Len(t, ids, 0)
})
}

View file

@ -61,6 +61,7 @@ import (
"github.com/mattermost/mattermost/server/v8/channels/jobs/plugins"
"github.com/mattermost/mattermost/server/v8/channels/jobs/post_persistent_notifications"
"github.com/mattermost/mattermost/server/v8/channels/jobs/product_notices"
"github.com/mattermost/mattermost/server/v8/channels/jobs/recap"
"github.com/mattermost/mattermost/server/v8/channels/jobs/refresh_materialized_views"
"github.com/mattermost/mattermost/server/v8/channels/jobs/resend_invitation_email"
"github.com/mattermost/mattermost/server/v8/channels/jobs/s3_path_migration"
@ -1624,6 +1625,12 @@ func (s *Server) initJobs() {
delete_dms_preferences_migration.MakeWorker(s.Jobs, s.Store(), New(ServerConnector(s.Channels()))),
nil)
s.Jobs.RegisterJobType(
model.JobTypeRecap,
recap.MakeWorker(s.Jobs, s.Store(), New(ServerConnector(s.Channels()))),
nil,
)
s.Jobs.RegisterJobType(
model.JobTypeDeleteExpiredPosts,
delete_expired_posts.MakeWorker(s.Jobs, s.Store(), New(ServerConnector(s.Channels()))),

View file

@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// SummarizePosts generates an AI summary of posts with highlights and action items
func (a *App) SummarizePosts(rctx request.CTX, userID string, posts []*model.Post, channelName, teamName string, agentID string) (*model.AIRecapSummaryResponse, *model.AppError) {
if len(posts) == 0 {
return &model.AIRecapSummaryResponse{Highlights: []string{}, ActionItems: []string{}}, nil
}
// Get site URL for permalink generation
siteURL := a.GetSiteURL()
// Build conversation context from posts and collect post IDs
conversationText, postIDs := buildConversationTextWithIDs(posts)
systemPrompt := "You are an expert at analyzing team conversations and extracting key information. Your task is to summarize a conversation from a Mattermost channel, identifying the most important highlights and any actionable items. Return ONLY valid JSON with 'highlights' and 'action_items' keys, each containing an array of strings. If there are no highlights or action items, return empty arrays. Do not make up information - only include items explicitly mentioned in the conversation."
userPrompt := fmt.Sprintf(`Analyze the following conversation from the "%s" channel and provide a summary.
Site URL: %s
Team Name: %s
Conversation:
%s
Available Post IDs: %s
Return a JSON object with:
- "highlights": array of key discussion points, decisions, or important information
- "action_items": array of tasks, todos, or action items mentioned
IMPORTANT INSTRUCTIONS:
1. When your summary includes a user's username, prepend an @ symbol to the username. For example if you return a highlight with text '<username> sent an update about project xyz', where <username> is 'john.smith', you should phrase is as '@john.smith sent an update about project xyz'.
2. For EACH highlight and action item, you MUST append a permalink to cite the source. The permalink should reference the most relevant post from the conversation. Format the permalink at the END of each item as: [PERMALINK:%s/%s/pl/<POST_ID>] where <POST_ID> is one of the available post IDs provided above. Choose the post ID that is most relevant to that specific highlight or action item.
Example format: "Team decided to migrate to microservices architecture [PERMALINK:%s/%s/pl/abc123xyz]"
Your response must be compacted valid JSON only, with no additional text, formatting, nor code blocks.`, channelName, siteURL, teamName, conversationText, strings.Join(postIDs, ", "), siteURL, teamName, siteURL, teamName)
// Create bridge client
sessionUserID := ""
if session := rctx.Session(); session != nil {
sessionUserID = session.UserId
}
client := a.getBridgeClient(sessionUserID)
completionRequest := agentclient.CompletionRequest{
Posts: []agentclient.Post{
{Role: "system", Message: systemPrompt},
{Role: "user", Message: userPrompt},
},
}
rctx.Logger().Debug("Calling AI agent for post summarization",
mlog.String("channel_name", channelName),
mlog.String("user_id", userID),
mlog.String("agent_id", agentID),
mlog.Int("post_count", len(posts)),
)
completion, err := client.AgentCompletion(agentID, completionRequest)
if err != nil {
return nil, model.NewAppError("SummarizePosts", "app.ai.summarize.agent_call_failed", nil, err.Error(), http.StatusInternalServerError)
}
var summary model.AIRecapSummaryResponse
if err := json.Unmarshal([]byte(completion), &summary); err != nil {
return nil, model.NewAppError("SummarizePosts", "app.ai.summarize.parse_failed", nil, err.Error(), http.StatusInternalServerError)
}
// Ensure arrays are never nil
if summary.Highlights == nil {
summary.Highlights = []string{}
}
if summary.ActionItems == nil {
summary.ActionItems = []string{}
}
rctx.Logger().Debug("AI summarization successful",
mlog.String("channel_name", channelName),
mlog.Int("highlights_count", len(summary.Highlights)),
mlog.Int("action_items_count", len(summary.ActionItems)),
)
return &summary, nil
}
func buildConversationTextWithIDs(posts []*model.Post) (string, []string) {
var sb strings.Builder
postIDs := make([]string, 0, len(posts))
for _, post := range posts {
// Collect post ID
postIDs = append(postIDs, post.Id)
// Posts should have Username populated by the caller
// For posts without username, use UserId as fallback
username := ""
if usernameProp := post.GetProp("username"); usernameProp != nil {
if usernameStr, ok := usernameProp.(string); ok {
username = usernameStr
}
}
if username == "" {
username = post.UserId
}
sb.WriteString(fmt.Sprintf("[%s] %s (Post ID: %s): %s\n",
time.UnixMilli(post.CreateAt).Format("15:04"),
username,
post.Id,
post.Message))
}
return sb.String(), postIDs
}

View file

@ -0,0 +1,65 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
)
func TestBuildConversationText(t *testing.T) {
t.Run("build conversation with posts", func(t *testing.T) {
posts := []*model.Post{
{
Id: model.NewId(),
Message: "Hello world",
UserId: "user1",
CreateAt: 1234567890000,
Props: model.StringInterface{
"username": "john_doe",
},
},
{
Id: model.NewId(),
Message: "How are you?",
UserId: "user2",
CreateAt: 1234567895000,
Props: model.StringInterface{
"username": "jane_smith",
},
},
}
result, _ := buildConversationTextWithIDs(posts)
assert.Contains(t, result, "john_doe")
assert.Contains(t, result, "jane_smith")
assert.Contains(t, result, "Hello world")
assert.Contains(t, result, "How are you?")
})
t.Run("build conversation with posts without username", func(t *testing.T) {
posts := []*model.Post{
{
Id: model.NewId(),
Message: "Test message",
UserId: "user123",
CreateAt: 1234567890000,
Props: model.StringInterface{},
},
}
result, _ := buildConversationTextWithIDs(posts)
// Should fallback to user ID when no username prop
assert.Contains(t, result, "user123")
assert.Contains(t, result, "Test message")
})
t.Run("build conversation with empty posts", func(t *testing.T) {
posts := []*model.Post{}
result, _ := buildConversationTextWithIDs(posts)
assert.Equal(t, "", result)
})
}

View file

@ -293,3 +293,5 @@ channels/db/migrations/postgres/000147_create_autotranslation_tables.down.sql
channels/db/migrations/postgres/000147_create_autotranslation_tables.up.sql
channels/db/migrations/postgres/000148_add_burn_on_read_messages.down.sql
channels/db/migrations/postgres/000148_add_burn_on_read_messages.up.sql
channels/db/migrations/postgres/000149_create_recaps.down.sql
channels/db/migrations/postgres/000149_create_recaps.up.sql

View file

@ -0,0 +1,12 @@
DROP INDEX IF EXISTS idx_recap_channels_channel_id;
DROP INDEX IF EXISTS idx_recap_channels_recap_id;
DROP TABLE IF EXISTS RecapChannels;
DROP INDEX IF EXISTS idx_recaps_bot_id;
DROP INDEX IF EXISTS idx_recaps_user_id_read_at;
DROP INDEX IF EXISTS idx_recaps_user_id_delete_at;
DROP INDEX IF EXISTS idx_recaps_create_at;
DROP INDEX IF EXISTS idx_recaps_user_id;
DROP TABLE IF EXISTS Recaps;

View file

@ -0,0 +1,37 @@
-- Recaps table: stores recap metadata
CREATE TABLE IF NOT EXISTS Recaps (
Id VARCHAR(26) PRIMARY KEY,
UserId VARCHAR(26) NOT NULL,
Title VARCHAR(255) NOT NULL,
CreateAt BIGINT NOT NULL,
UpdateAt BIGINT NOT NULL,
DeleteAt BIGINT NOT NULL,
TotalMessageCount INT NOT NULL,
Status VARCHAR(32) NOT NULL,
ReadAt BIGINT DEFAULT 0 NOT NULL,
BotID VARCHAR(26) DEFAULT '' NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_recaps_user_id ON Recaps(UserId);
CREATE INDEX IF NOT EXISTS idx_recaps_create_at ON Recaps(CreateAt);
CREATE INDEX IF NOT EXISTS idx_recaps_user_id_delete_at ON Recaps(UserId, DeleteAt);
CREATE INDEX IF NOT EXISTS idx_recaps_user_id_read_at ON Recaps(UserId, ReadAt);
CREATE INDEX IF NOT EXISTS idx_recaps_bot_id ON Recaps(BotID);
-- RecapChannels table: stores per-channel summaries
CREATE TABLE IF NOT EXISTS RecapChannels (
Id VARCHAR(26) PRIMARY KEY,
RecapId VARCHAR(26) NOT NULL,
ChannelId VARCHAR(26) NOT NULL,
ChannelName VARCHAR(64) NOT NULL,
Highlights TEXT,
ActionItems TEXT,
SourcePostIds TEXT,
CreateAt BIGINT NOT NULL,
FOREIGN KEY (RecapId) REFERENCES Recaps(Id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_recap_channels_recap_id ON RecapChannels(RecapId);
CREATE INDEX IF NOT EXISTS idx_recap_channels_channel_id ON RecapChannels(ChannelId);

View file

@ -0,0 +1,129 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package recap
import (
"fmt"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type AppIface interface {
ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, agentID string) (*model.RecapChannelResult, *model.AppError)
Publish(message *model.WebSocketEvent)
}
func MakeWorker(jobServer *jobs.JobServer, storeInstance store.Store, appInstance AppIface) *jobs.SimpleWorker {
isEnabled := func(cfg *model.Config) bool {
return cfg.FeatureFlags.EnableAIRecaps
}
execute := func(logger mlog.LoggerIFace, job *model.Job) error {
defer jobServer.HandleJobPanic(logger, job)
return processRecapJob(logger, job, storeInstance, appInstance, func(progress int64) {
_ = jobServer.SetJobProgress(job, progress)
})
}
return jobs.NewSimpleWorker("Recap", jobServer, execute, isEnabled)
}
func processRecapJob(logger mlog.LoggerIFace, job *model.Job, storeInstance store.Store, appInstance AppIface, setProgress func(int64)) error {
recapID := job.Data["recap_id"]
userID := job.Data["user_id"]
channelIDs := strings.Split(job.Data["channel_ids"], ",")
agentID := job.Data["agent_id"]
logger.Info("Starting recap job",
mlog.String("recap_id", recapID),
mlog.String("agent_id", agentID),
mlog.Int("channel_count", len(channelIDs)))
// Update status to processing
_ = storeInstance.Recap().UpdateRecapStatus(recapID, model.RecapStatusProcessing)
publishRecapUpdate(appInstance, recapID, userID)
totalMessages := 0
successfulChannels := []string{}
failedChannels := []string{}
for i, channelID := range channelIDs {
// Update progress
progress := int64((i * 100) / len(channelIDs))
if setProgress != nil {
setProgress(progress)
}
// Process the channel
result, err := appInstance.ProcessRecapChannel(request.EmptyContext(logger), recapID, channelID, userID, agentID)
if err != nil {
logger.Warn("Failed to process channel",
mlog.String("channel_id", channelID),
mlog.Err(err))
failedChannels = append(failedChannels, channelID)
continue
}
if !result.Success {
logger.Warn("Channel processing unsuccessful", mlog.String("channel_id", channelID))
failedChannels = append(failedChannels, channelID)
continue
}
totalMessages += result.MessageCount
successfulChannels = append(successfulChannels, channelID)
}
// Update recap with final data (title is already set by user in CreateRecap)
recap, _ := storeInstance.Recap().GetRecap(recapID)
recap.TotalMessageCount = totalMessages
recap.UpdateAt = model.GetMillis()
if len(failedChannels) > 0 && len(successfulChannels) == 0 {
recap.Status = model.RecapStatusFailed
_, err := storeInstance.Recap().UpdateRecap(recap)
if err != nil {
logger.Error("Failed to update recap", mlog.Err(err))
return fmt.Errorf("failed to update recap: %w", err)
}
publishRecapUpdate(appInstance, recapID, userID)
return fmt.Errorf("all channels failed to process")
} else if len(failedChannels) > 0 {
recap.Status = model.RecapStatusCompleted
_, err := storeInstance.Recap().UpdateRecap(recap)
if err != nil {
logger.Error("Failed to update recap", mlog.Err(err))
return fmt.Errorf("failed to update recap: %w", err)
}
publishRecapUpdate(appInstance, recapID, userID)
logger.Warn("Some channels failed", mlog.Int("failed_count", len(failedChannels)))
// Job succeeds with warning
} else {
recap.Status = model.RecapStatusCompleted
_, err := storeInstance.Recap().UpdateRecap(recap)
if err != nil {
logger.Error("Failed to update recap", mlog.Err(err))
return fmt.Errorf("failed to update recap: %w", err)
}
publishRecapUpdate(appInstance, recapID, userID)
}
logger.Info("Recap job completed",
mlog.String("recap_id", recapID),
mlog.Int("successful_channels", len(successfulChannels)),
mlog.Int("failed_channels", len(failedChannels)))
return nil
}
func publishRecapUpdate(appInstance AppIface, recapID, userID string) {
message := model.NewWebSocketEvent(model.WebsocketEventRecapUpdated, "", "", userID, nil, "")
message.Add("recap_id", recapID)
appInstance.Publish(message)
}

View file

@ -0,0 +1,128 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package recap
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
type MockAppIface struct {
mock.Mock
}
func (m *MockAppIface) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, agentID string) (*model.RecapChannelResult, *model.AppError) {
args := m.Called(rctx, recapID, channelID, userID, agentID)
if args.Get(0) == nil {
return nil, args.Get(1).(*model.AppError)
}
return args.Get(0).(*model.RecapChannelResult), nil
}
func (m *MockAppIface) Publish(message *model.WebSocketEvent) {
m.Called(message)
}
func TestProcessRecapJob(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
job := &model.Job{
Data: map[string]string{
"recap_id": "recap1",
"user_id": "user1",
"channel_ids": "channel1,channel2",
"agent_id": "agent1",
},
}
t.Run("successful processing", func(t *testing.T) {
mockStore := &mocks.Store{}
mockRecapStore := &mocks.RecapStore{}
mockStore.On("Recap").Return(mockRecapStore)
mockApp := &MockAppIface{}
// Setup expectations
mockRecapStore.On("UpdateRecapStatus", "recap1", model.RecapStatusProcessing).Return(nil)
mockApp.On("Publish", mock.Anything).Return()
mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel1", "user1", "agent1").Return(&model.RecapChannelResult{
ChannelID: "channel1",
Success: true,
MessageCount: 10,
}, nil)
mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel2", "user1", "agent1").Return(&model.RecapChannelResult{
ChannelID: "channel2",
Success: true,
MessageCount: 5,
}, nil)
recap := &model.Recap{Id: "recap1"}
mockRecapStore.On("GetRecap", "recap1").Return(recap, nil)
mockRecapStore.On("UpdateRecap", mock.MatchedBy(func(r *model.Recap) bool {
return r.TotalMessageCount == 15 && r.Status == model.RecapStatusCompleted
})).Return(recap, nil)
err := processRecapJob(logger, job, mockStore, mockApp, nil)
require.NoError(t, err)
})
t.Run("partial failure", func(t *testing.T) {
mockStore := &mocks.Store{}
mockRecapStore := &mocks.RecapStore{}
mockStore.On("Recap").Return(mockRecapStore)
mockApp := &MockAppIface{}
mockRecapStore.On("UpdateRecapStatus", "recap1", model.RecapStatusProcessing).Return(nil)
mockApp.On("Publish", mock.Anything).Return()
mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel1", "user1", "agent1").Return(&model.RecapChannelResult{
ChannelID: "channel1",
Success: true,
MessageCount: 10,
}, nil)
mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel2", "user1", "agent1").Return(nil, model.NewAppError("fail", "fail", nil, "", 500))
recap := &model.Recap{Id: "recap1"}
mockRecapStore.On("GetRecap", "recap1").Return(recap, nil)
mockRecapStore.On("UpdateRecap", mock.MatchedBy(func(r *model.Recap) bool {
return r.TotalMessageCount == 10 && r.Status == model.RecapStatusCompleted
})).Return(recap, nil)
err := processRecapJob(logger, job, mockStore, mockApp, nil)
require.NoError(t, err)
})
t.Run("complete failure", func(t *testing.T) {
mockStore := &mocks.Store{}
mockRecapStore := &mocks.RecapStore{}
mockStore.On("Recap").Return(mockRecapStore)
mockApp := &MockAppIface{}
mockRecapStore.On("UpdateRecapStatus", "recap1", model.RecapStatusProcessing).Return(nil)
mockApp.On("Publish", mock.Anything).Return()
mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel1", "user1", "agent1").Return(nil, model.NewAppError("fail", "fail", nil, "", 500))
mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel2", "user1", "agent1").Return(nil, model.NewAppError("fail", "fail", nil, "", 500))
recap := &model.Recap{Id: "recap1"}
mockRecapStore.On("GetRecap", "recap1").Return(recap, nil)
mockRecapStore.On("UpdateRecap", mock.MatchedBy(func(r *model.Recap) bool {
return r.TotalMessageCount == 0 && r.Status == model.RecapStatusFailed
})).Return(recap, nil)
err := processRecapJob(logger, job, mockStore, mockApp, nil)
require.Error(t, err)
require.Equal(t, "all channels failed to process", err.Error())
})
}

View file

@ -0,0 +1,299 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"encoding/json"
"fmt"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
)
var (
recapColumns = []string{
"Id",
"UserId",
"Title",
"CreateAt",
"UpdateAt",
"DeleteAt",
"ReadAt",
"TotalMessageCount",
"Status",
"BotID",
}
recapChannelColumns = []string{
"Id",
"RecapId",
"ChannelId",
"ChannelName",
"Highlights",
"ActionItems",
"SourcePostIds",
"CreateAt",
}
)
type SqlRecapStore struct {
*SqlStore
recapSelectQuery sq.SelectBuilder
recapChannelSelectQuery sq.SelectBuilder
}
func newSqlRecapStore(sqlStore *SqlStore) store.RecapStore {
s := &SqlRecapStore{
SqlStore: sqlStore,
}
s.recapSelectQuery = s.getQueryBuilder().
Select(recapColumns...).
From("Recaps")
s.recapChannelSelectQuery = s.getQueryBuilder().
Select(recapChannelColumns...).
From("RecapChannels")
return s
}
func (s *SqlRecapStore) recapToMap(recap *model.Recap) map[string]any {
return map[string]any{
"Id": recap.Id,
"UserId": recap.UserId,
"Title": recap.Title,
"CreateAt": recap.CreateAt,
"UpdateAt": recap.UpdateAt,
"DeleteAt": recap.DeleteAt,
"ReadAt": recap.ReadAt,
"TotalMessageCount": recap.TotalMessageCount,
"Status": recap.Status,
"BotID": recap.BotID,
}
}
func (s *SqlRecapStore) recapChannelToMap(rc *model.RecapChannel) (map[string]any, error) {
highlightsJSON, err := json.Marshal(rc.Highlights)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal Highlights")
}
actionItemsJSON, err := json.Marshal(rc.ActionItems)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal ActionItems")
}
sourcePostIdsJSON, err := json.Marshal(rc.SourcePostIds)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal SourcePostIds")
}
return map[string]any{
"Id": rc.Id,
"RecapId": rc.RecapId,
"ChannelId": rc.ChannelId,
"ChannelName": rc.ChannelName,
"Highlights": string(highlightsJSON),
"ActionItems": string(actionItemsJSON),
"SourcePostIds": string(sourcePostIdsJSON),
"CreateAt": rc.CreateAt,
}, nil
}
func (s *SqlRecapStore) SaveRecap(recap *model.Recap) (*model.Recap, error) {
query := s.getQueryBuilder().
Insert("Recaps").
SetMap(s.recapToMap(recap))
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return nil, errors.Wrap(err, "failed to save Recap")
}
return recap, nil
}
func (s *SqlRecapStore) GetRecap(id string) (*model.Recap, error) {
var recap model.Recap
query := s.recapSelectQuery.Where(sq.Eq{"Id": id})
if err := s.GetReplica().GetBuilder(&recap, query); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("Recap", id)
}
return nil, errors.Wrapf(err, "failed to get Recap with id=%s", id)
}
return &recap, nil
}
func (s *SqlRecapStore) GetRecapsForUser(userId string, page, perPage int) ([]*model.Recap, error) {
offset := page * perPage
var recaps []*model.Recap
query := s.recapSelectQuery.
Where(sq.Eq{"UserId": userId, "DeleteAt": 0}).
OrderBy("CreateAt DESC").
Limit(uint64(perPage)).
Offset(uint64(offset))
if err := s.GetReplica().SelectBuilder(&recaps, query); err != nil {
return nil, errors.Wrapf(err, "failed to get Recaps for userId=%s", userId)
}
return recaps, nil
}
func (s *SqlRecapStore) UpdateRecap(recap *model.Recap) (*model.Recap, error) {
query := s.getQueryBuilder().
Update("Recaps").
SetMap(map[string]any{
"Title": recap.Title,
"UpdateAt": recap.UpdateAt,
"TotalMessageCount": recap.TotalMessageCount,
"Status": recap.Status,
}).
Where(sq.Eq{"Id": recap.Id})
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return nil, errors.Wrapf(err, "failed to update Recap with id=%s", recap.Id)
}
return recap, nil
}
func (s *SqlRecapStore) UpdateRecapStatus(id, status string) error {
updateAt := model.GetMillis()
query := s.getQueryBuilder().
Update("Recaps").
SetMap(map[string]any{
"Status": status,
"UpdateAt": updateAt,
}).
Where(sq.Eq{"Id": id})
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return errors.Wrapf(err, "failed to update Recap status for id=%s", id)
}
return nil
}
func (s *SqlRecapStore) MarkRecapAsRead(id string) error {
now := model.GetMillis()
query := s.getQueryBuilder().
Update("Recaps").
SetMap(map[string]any{
"ReadAt": now,
"UpdateAt": now,
}).
Where(sq.Eq{"Id": id, "ReadAt": 0})
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return errors.Wrapf(err, "failed to mark Recap as read for id=%s", id)
}
return nil
}
func (s *SqlRecapStore) DeleteRecap(id string) error {
deleteAt := model.GetMillis()
query := s.getQueryBuilder().
Update("Recaps").
SetMap(map[string]any{
"DeleteAt": deleteAt,
}).
Where(sq.Eq{"Id": id})
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return errors.Wrapf(err, "failed to delete Recap with id=%s", id)
}
return nil
}
func (s *SqlRecapStore) DeleteRecapChannels(recapId string) error {
query := s.getQueryBuilder().
Delete("RecapChannels").
Where(sq.Eq{"RecapId": recapId})
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return errors.Wrapf(err, "failed to delete RecapChannels for recapId=%s", recapId)
}
return nil
}
func (s *SqlRecapStore) SaveRecapChannel(recapChannel *model.RecapChannel) error {
rcMap, err := s.recapChannelToMap(recapChannel)
if err != nil {
return err
}
query := s.getQueryBuilder().
Insert("RecapChannels").
SetMap(rcMap)
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return errors.Wrap(err, "failed to save RecapChannel")
}
return nil
}
func (s *SqlRecapStore) GetRecapChannelsByRecapId(recapId string) ([]*model.RecapChannel, error) {
query := s.recapChannelSelectQuery.
Where(sq.Eq{"RecapId": recapId}).
OrderBy("CreateAt ASC")
var dbRecapChannels []struct {
Id string
RecapId string
ChannelId string
ChannelName string
Highlights string
ActionItems string
SourcePostIds string
CreateAt int64
}
if err := s.GetReplica().SelectBuilder(&dbRecapChannels, query); err != nil {
return nil, errors.Wrapf(err, "failed to get RecapChannels for recapId=%s", recapId)
}
recapChannels := make([]*model.RecapChannel, 0, len(dbRecapChannels))
for _, dbRC := range dbRecapChannels {
rc := &model.RecapChannel{
Id: dbRC.Id,
RecapId: dbRC.RecapId,
ChannelId: dbRC.ChannelId,
ChannelName: dbRC.ChannelName,
CreateAt: dbRC.CreateAt,
}
// Unmarshal JSON strings back to arrays
if err := json.Unmarshal([]byte(dbRC.Highlights), &rc.Highlights); err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal Highlights for recapChannel id=%s", dbRC.Id))
}
if err := json.Unmarshal([]byte(dbRC.ActionItems), &rc.ActionItems); err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal ActionItems for recapChannel id=%s", dbRC.Id))
}
if err := json.Unmarshal([]byte(dbRC.SourcePostIds), &rc.SourcePostIds); err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal SourcePostIds for recapChannel id=%s", dbRC.Id))
}
recapChannels = append(recapChannels, rc)
}
return recapChannels, nil
}

View file

@ -0,0 +1,195 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRecapStore(t *testing.T) {
StoreTest(t, func(t *testing.T, rctx request.CTX, ss store.Store) {
t.Run("SaveAndGetRecap", func(t *testing.T) {
recap := &model.Recap{
Id: model.NewId(),
UserId: model.NewId(),
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusPending,
BotID: "test-bot-id",
}
savedRecap, err := ss.Recap().SaveRecap(recap)
require.NoError(t, err)
assert.Equal(t, recap.Id, savedRecap.Id)
assert.Equal(t, recap.UserId, savedRecap.UserId)
assert.Equal(t, recap.Title, savedRecap.Title)
assert.Equal(t, recap.BotID, savedRecap.BotID)
retrievedRecap, err := ss.Recap().GetRecap(recap.Id)
require.NoError(t, err)
assert.Equal(t, recap.Id, retrievedRecap.Id)
assert.Equal(t, recap.UserId, retrievedRecap.UserId)
assert.Equal(t, recap.Title, retrievedRecap.Title)
assert.Equal(t, recap.TotalMessageCount, retrievedRecap.TotalMessageCount)
assert.Equal(t, recap.Status, retrievedRecap.Status)
assert.Equal(t, recap.BotID, retrievedRecap.BotID)
})
t.Run("GetRecapsForUser", func(t *testing.T) {
userId := model.NewId()
// Create multiple recaps for the same user
for range 3 {
recap := &model.Recap{
Id: model.NewId(),
UserId: userId,
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
BotID: "test-bot-id",
}
_, err := ss.Recap().SaveRecap(recap)
require.NoError(t, err)
}
recaps, err := ss.Recap().GetRecapsForUser(userId, 0, 10)
require.NoError(t, err)
assert.Len(t, recaps, 3)
})
t.Run("UpdateRecapStatus", func(t *testing.T) {
recap := &model.Recap{
Id: model.NewId(),
UserId: model.NewId(),
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusPending,
BotID: "test-bot-id",
}
_, err := ss.Recap().SaveRecap(recap)
require.NoError(t, err)
err = ss.Recap().UpdateRecapStatus(recap.Id, model.RecapStatusCompleted)
require.NoError(t, err)
updatedRecap, err := ss.Recap().GetRecap(recap.Id)
require.NoError(t, err)
assert.Equal(t, model.RecapStatusCompleted, updatedRecap.Status)
})
t.Run("SaveAndGetRecapChannels", func(t *testing.T) {
recapId := model.NewId()
// Create a recap first
recap := &model.Recap{
Id: recapId,
UserId: model.NewId(),
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusPending,
BotID: "test-bot-id",
}
_, err := ss.Recap().SaveRecap(recap)
require.NoError(t, err)
// Create recap channels
recapChannel1 := &model.RecapChannel{
Id: model.NewId(),
RecapId: recapId,
ChannelId: model.NewId(),
ChannelName: "Test Channel 1",
Highlights: []string{"Highlight 1", "Highlight 2"},
ActionItems: []string{"Action 1"},
SourcePostIds: []string{model.NewId(), model.NewId()},
CreateAt: model.GetMillis(),
}
recapChannel2 := &model.RecapChannel{
Id: model.NewId(),
RecapId: recapId,
ChannelId: model.NewId(),
ChannelName: "Test Channel 2",
Highlights: []string{},
ActionItems: []string{"Action 2", "Action 3"},
SourcePostIds: []string{model.NewId()},
CreateAt: model.GetMillis(),
}
err = ss.Recap().SaveRecapChannel(recapChannel1)
require.NoError(t, err)
err = ss.Recap().SaveRecapChannel(recapChannel2)
require.NoError(t, err)
// Retrieve recap channels
channels, err := ss.Recap().GetRecapChannelsByRecapId(recapId)
require.NoError(t, err)
assert.Len(t, channels, 2)
// Verify data integrity
for _, ch := range channels {
if ch.Id == recapChannel1.Id {
assert.Equal(t, recapChannel1.ChannelName, ch.ChannelName)
assert.Equal(t, recapChannel1.Highlights, ch.Highlights)
assert.Equal(t, recapChannel1.ActionItems, ch.ActionItems)
assert.Equal(t, recapChannel1.SourcePostIds, ch.SourcePostIds)
} else if ch.Id == recapChannel2.Id {
assert.Equal(t, recapChannel2.ChannelName, ch.ChannelName)
assert.Equal(t, recapChannel2.Highlights, ch.Highlights)
assert.Equal(t, recapChannel2.ActionItems, ch.ActionItems)
assert.Equal(t, recapChannel2.SourcePostIds, ch.SourcePostIds)
}
}
})
t.Run("DeleteRecap", func(t *testing.T) {
recap := &model.Recap{
Id: model.NewId(),
UserId: model.NewId(),
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
BotID: "test-bot-id",
}
_, err := ss.Recap().SaveRecap(recap)
require.NoError(t, err)
err = ss.Recap().DeleteRecap(recap.Id)
require.NoError(t, err)
// Verify soft delete - should not appear in user's recaps
recaps, err := ss.Recap().GetRecapsForUser(recap.UserId, 0, 10)
require.NoError(t, err)
assert.Len(t, recaps, 0)
})
})
}

View file

@ -112,6 +112,7 @@ type SqlStoreStores struct {
Attributes store.AttributesStore
autotranslation store.AutoTranslationStore
ContentFlagging store.ContentFlaggingStore
recap store.RecapStore
readReceipt store.ReadReceiptStore
temporaryPost store.TemporaryPostStore
}
@ -265,6 +266,7 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface
store.stores.Attributes = newSqlAttributesStore(store, metrics)
store.stores.autotranslation = newSqlAutoTranslationStore(store)
store.stores.ContentFlagging = newContentFlaggingStore(store)
store.stores.recap = newSqlRecapStore(store)
store.stores.readReceipt = newSqlReadReceiptStore(store, metrics)
store.stores.temporaryPost = newSqlTemporaryPostStore(store, metrics)
@ -886,6 +888,10 @@ func (ss *SqlStore) AutoTranslation() store.AutoTranslationStore {
return ss.stores.autotranslation
}
func (ss *SqlStore) Recap() store.RecapStore {
return ss.stores.recap
}
func (ss *SqlStore) ReadReceipt() store.ReadReceiptStore {
return ss.stores.readReceipt
}

View file

@ -98,6 +98,7 @@ type Store interface {
AutoTranslation() AutoTranslationStore
GetSchemaDefinition() (*model.SupportPacketDatabaseSchema, error)
ContentFlagging() ContentFlaggingStore
Recap() RecapStore
ReadReceipt() ReadReceiptStore
TemporaryPost() TemporaryPostStore
}
@ -1291,3 +1292,16 @@ type ThreadMembershipImportData struct {
// UnreadMentions is the number of unread mentions to set the UnreadMentions field to.
UnreadMentions int64
}
type RecapStore interface {
SaveRecap(recap *model.Recap) (*model.Recap, error)
UpdateRecap(recap *model.Recap) (*model.Recap, error)
GetRecap(id string) (*model.Recap, error)
GetRecapsForUser(userId string, page, perPage int) ([]*model.Recap, error)
UpdateRecapStatus(id, status string) error
MarkRecapAsRead(id string) error
DeleteRecap(id string) error
DeleteRecapChannels(recapId string) error
SaveRecapChannel(recapChannel *model.RecapChannel) error
GetRecapChannelsByRecapId(recapId string) ([]*model.RecapChannel, error)
}

View file

@ -0,0 +1,269 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make store-mocks`.
package mocks
import (
model "github.com/mattermost/mattermost/server/public/model"
mock "github.com/stretchr/testify/mock"
)
// RecapStore is an autogenerated mock type for the RecapStore type
type RecapStore struct {
mock.Mock
}
// DeleteRecap provides a mock function with given fields: id
func (_m *RecapStore) DeleteRecap(id string) error {
ret := _m.Called(id)
if len(ret) == 0 {
panic("no return value specified for DeleteRecap")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(id)
} else {
r0 = ret.Error(0)
}
return r0
}
// DeleteRecapChannels provides a mock function with given fields: recapId
func (_m *RecapStore) DeleteRecapChannels(recapId string) error {
ret := _m.Called(recapId)
if len(ret) == 0 {
panic("no return value specified for DeleteRecapChannels")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(recapId)
} else {
r0 = ret.Error(0)
}
return r0
}
// GetRecap provides a mock function with given fields: id
func (_m *RecapStore) GetRecap(id string) (*model.Recap, error) {
ret := _m.Called(id)
if len(ret) == 0 {
panic("no return value specified for GetRecap")
}
var r0 *model.Recap
var r1 error
if rf, ok := ret.Get(0).(func(string) (*model.Recap, error)); ok {
return rf(id)
}
if rf, ok := ret.Get(0).(func(string) *model.Recap); ok {
r0 = rf(id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Recap)
}
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetRecapChannelsByRecapId provides a mock function with given fields: recapId
func (_m *RecapStore) GetRecapChannelsByRecapId(recapId string) ([]*model.RecapChannel, error) {
ret := _m.Called(recapId)
if len(ret) == 0 {
panic("no return value specified for GetRecapChannelsByRecapId")
}
var r0 []*model.RecapChannel
var r1 error
if rf, ok := ret.Get(0).(func(string) ([]*model.RecapChannel, error)); ok {
return rf(recapId)
}
if rf, ok := ret.Get(0).(func(string) []*model.RecapChannel); ok {
r0 = rf(recapId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.RecapChannel)
}
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(recapId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetRecapsForUser provides a mock function with given fields: userId, page, perPage
func (_m *RecapStore) GetRecapsForUser(userId string, page int, perPage int) ([]*model.Recap, error) {
ret := _m.Called(userId, page, perPage)
if len(ret) == 0 {
panic("no return value specified for GetRecapsForUser")
}
var r0 []*model.Recap
var r1 error
if rf, ok := ret.Get(0).(func(string, int, int) ([]*model.Recap, error)); ok {
return rf(userId, page, perPage)
}
if rf, ok := ret.Get(0).(func(string, int, int) []*model.Recap); ok {
r0 = rf(userId, page, perPage)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.Recap)
}
}
if rf, ok := ret.Get(1).(func(string, int, int) error); ok {
r1 = rf(userId, page, perPage)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MarkRecapAsRead provides a mock function with given fields: id
func (_m *RecapStore) MarkRecapAsRead(id string) error {
ret := _m.Called(id)
if len(ret) == 0 {
panic("no return value specified for MarkRecapAsRead")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(id)
} else {
r0 = ret.Error(0)
}
return r0
}
// SaveRecap provides a mock function with given fields: recap
func (_m *RecapStore) SaveRecap(recap *model.Recap) (*model.Recap, error) {
ret := _m.Called(recap)
if len(ret) == 0 {
panic("no return value specified for SaveRecap")
}
var r0 *model.Recap
var r1 error
if rf, ok := ret.Get(0).(func(*model.Recap) (*model.Recap, error)); ok {
return rf(recap)
}
if rf, ok := ret.Get(0).(func(*model.Recap) *model.Recap); ok {
r0 = rf(recap)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Recap)
}
}
if rf, ok := ret.Get(1).(func(*model.Recap) error); ok {
r1 = rf(recap)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SaveRecapChannel provides a mock function with given fields: recapChannel
func (_m *RecapStore) SaveRecapChannel(recapChannel *model.RecapChannel) error {
ret := _m.Called(recapChannel)
if len(ret) == 0 {
panic("no return value specified for SaveRecapChannel")
}
var r0 error
if rf, ok := ret.Get(0).(func(*model.RecapChannel) error); ok {
r0 = rf(recapChannel)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateRecap provides a mock function with given fields: recap
func (_m *RecapStore) UpdateRecap(recap *model.Recap) (*model.Recap, error) {
ret := _m.Called(recap)
if len(ret) == 0 {
panic("no return value specified for UpdateRecap")
}
var r0 *model.Recap
var r1 error
if rf, ok := ret.Get(0).(func(*model.Recap) (*model.Recap, error)); ok {
return rf(recap)
}
if rf, ok := ret.Get(0).(func(*model.Recap) *model.Recap); ok {
r0 = rf(recap)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Recap)
}
}
if rf, ok := ret.Get(1).(func(*model.Recap) error); ok {
r1 = rf(recap)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UpdateRecapStatus provides a mock function with given fields: id, status
func (_m *RecapStore) UpdateRecapStatus(id string, status string) error {
ret := _m.Called(id, status)
if len(ret) == 0 {
panic("no return value specified for UpdateRecapStatus")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(id, status)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewRecapStore creates a new instance of RecapStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewRecapStore(t interface {
mock.TestingT
Cleanup(func())
}) *RecapStore {
mock := &RecapStore{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View file

@ -966,6 +966,26 @@ func (_m *Store) Reaction() store.ReactionStore {
return r0
}
// Recap provides a mock function with no fields
func (_m *Store) Recap() store.RecapStore {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Recap")
}
var r0 store.RecapStore
if rf, ok := ret.Get(0).(func() store.RecapStore); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(store.RecapStore)
}
}
return r0
}
// ReadReceipt provides a mock function with no fields
func (_m *Store) ReadReceipt() store.ReadReceiptStore {
ret := _m.Called()

View file

@ -71,6 +71,7 @@ type Store struct {
AttributesStore mocks.AttributesStore
AutoTranslationStore mocks.AutoTranslationStore
ContentFlaggingStore mocks.ContentFlaggingStore
RecapStore mocks.RecapStore
ReadReceiptStore mocks.ReadReceiptStore
TemporaryPostStore mocks.TemporaryPostStore
}
@ -169,6 +170,9 @@ func (s *Store) AutoTranslation() store.AutoTranslationStore {
func (s *Store) ContentFlagging() store.ContentFlaggingStore {
return &s.ContentFlaggingStore
}
func (s *Store) Recap() store.RecapStore {
return &s.RecapStore
}
func (s *Store) ReadReceipt() store.ReadReceiptStore {
return &s.ReadReceiptStore
}
@ -227,6 +231,7 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool {
&s.AttributesStore,
&s.AutoTranslationStore,
&s.ContentFlaggingStore,
&s.RecapStore,
&s.ReadReceiptStore,
&s.TemporaryPostStore,
)

View file

@ -768,6 +768,17 @@ func (c *Context) RequireContentReviewerId() *Context {
return c
}
func (c *Context) RequireRecapId() *Context {
if c.Err != nil {
return c
}
if !model.IsValidId(c.Params.RecapId) {
c.SetInvalidURLParam("recap_id")
}
return c
}
func (c *Context) GetRemoteID(r *http.Request) string {
return r.Header.Get(model.HeaderRemoteclusterId)
}

View file

@ -55,6 +55,7 @@ type Params struct {
Service string
JobId string
JobType string
RecapId string
ActionId string
RoleId string
RoleName string
@ -169,6 +170,7 @@ func ParamsFromRequest(r *http.Request) *Params {
params.EmojiName = props["emoji_name"]
params.JobId = props["job_id"]
params.JobType = props["job_type"]
params.RecapId = props["recap_id"]
params.ActionId = props["action_id"]
params.RoleId = props["role_id"]
params.RoleName = props["role_name"]

View file

@ -3018,6 +3018,14 @@
"id": "api.reaction.save_reaction.user_id.app_error",
"translation": "You cannot save reaction for the other user."
},
{
"id": "api.recap.disabled.app_error",
"translation": "This feature is not enabled."
},
{
"id": "api.recap.permission_denied",
"translation": "You do not have permission to access this recap."
},
{
"id": "api.remote_cluster.accept_invitation_error",
"translation": "Could not accept the remote cluster invitation"
@ -4850,6 +4858,14 @@
"id": "app.agents.get_services.bridge_call_failed",
"translation": "Bridge call failed."
},
{
"id": "app.ai.summarize.agent_call_failed",
"translation": "AI agent call failed."
},
{
"id": "app.ai.summarize.parse_failed",
"translation": "Failed to parse AI summarization response."
},
{
"id": "app.analytics.getanalytics.internal_error",
"translation": "Unable to get the analytics."
@ -7366,6 +7382,58 @@
"id": "app.reaction.save.save.too_many_reactions",
"translation": "Reaction limit has been reached for this post."
},
{
"id": "app.recap.delete.app_error",
"translation": "Failed to delete recap."
},
{
"id": "app.recap.delete_channels.app_error",
"translation": "Failed to delete recap channels."
},
{
"id": "app.recap.get.app_error",
"translation": "Failed to get recap."
},
{
"id": "app.recap.get_channel.app_error",
"translation": "Failed to get channel."
},
{
"id": "app.recap.get_channels.app_error",
"translation": "Failed to get recap channels."
},
{
"id": "app.recap.get_last_viewed.app_error",
"translation": "Failed to get last viewed timestamp."
},
{
"id": "app.recap.get_team.app_error",
"translation": "Failed to get team."
},
{
"id": "app.recap.list.app_error",
"translation": "Failed to get recaps."
},
{
"id": "app.recap.mark_read.app_error",
"translation": "Failed to mark recap as read."
},
{
"id": "app.recap.permission_denied",
"translation": "No permission for recap."
},
{
"id": "app.recap.save.app_error",
"translation": "Failed to save recap."
},
{
"id": "app.recap.save_channel.app_error",
"translation": "Failed to save recap channel."
},
{
"id": "app.recap.update.app_error",
"translation": "Failed to update recap."
},
{
"id": "app.recover.delete.app_error",
"translation": "Unable to delete token."

View file

@ -88,6 +88,9 @@ type FeatureFlags struct {
// FEATURE_FLAG_REMOVAL: EnableAIPluginBridge
EnableAIPluginBridge bool
// FEATURE_FLAG_REMOVAL: EnableAIRecaps - Remove this when GA is released
EnableAIRecaps bool
}
func (f *FeatureFlags) SetDefaults() {
@ -129,6 +132,8 @@ func (f *FeatureFlags) SetDefaults() {
// FEATURE_FLAG_REMOVAL: EnableAIPluginBridge - Remove this default when MVP is to be released
f.EnableAIPluginBridge = false
f.EnableAIRecaps = false
}
// ToMap returns the feature flags as a map[string]string

View file

@ -45,6 +45,7 @@ const (
JobTypeMobileSessionMetadata = "mobile_session_metadata"
JobTypeAccessControlSync = "access_control_sync"
JobTypePushProxyAuth = "push_proxy_auth"
JobTypeRecap = "recap"
JobTypeDeleteExpiredPosts = "delete_expired_posts"
JobStatusPending = "pending"

View file

@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type Recap struct {
Id string `json:"id"`
UserId string `json:"user_id"`
Title string `json:"title"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
ReadAt int64 `json:"read_at"`
TotalMessageCount int `json:"total_message_count"`
Status string `json:"status"`
BotID string `json:"bot_id"`
Channels []*RecapChannel `json:"channels,omitempty"`
}
type RecapChannel struct {
Id string `json:"id"`
RecapId string `json:"recap_id"`
ChannelId string `json:"channel_id"`
ChannelName string `json:"channel_name"`
Highlights []string `json:"highlights"`
ActionItems []string `json:"action_items"`
SourcePostIds []string `json:"source_post_ids"`
CreateAt int64 `json:"create_at"`
}
type CreateRecapRequest struct {
Title string `json:"title"`
ChannelIds []string `json:"channel_ids"`
AgentID string `json:"agent_id"`
}
type AIRecapSummaryResponse struct {
Highlights []string `json:"highlights"`
ActionItems []string `json:"action_items"`
}
// RecapChannelResult represents the result of processing a single channel for a recap
type RecapChannelResult struct {
ChannelID string
MessageCount int
Success bool
}
const (
RecapStatusPending = "pending"
RecapStatusProcessing = "processing"
RecapStatusCompleted = "completed"
RecapStatusFailed = "failed"
)

View file

@ -99,6 +99,7 @@ const (
WebsocketEventCPAFieldDeleted WebsocketEventType = "custom_profile_attributes_field_deleted"
WebsocketEventCPAValuesUpdated WebsocketEventType = "custom_profile_attributes_values_updated"
WebsocketContentFlaggingReportValueUpdated WebsocketEventType = "content_flagging_report_value_updated"
WebsocketEventRecapUpdated WebsocketEventType = "recap_updated"
WebsocketEventPostRevealed WebsocketEventType = "post_revealed"
WebsocketEventPostBurned WebsocketEventType = "post_burned"
WebsocketEventBurnOnReadAllRevealed WebsocketEventType = "burn_on_read_all_revealed"

View file

@ -51,6 +51,7 @@ import {
receivedNewPost,
receivedPost,
} from 'mattermost-redux/actions/posts';
import {getRecap} from 'mattermost-redux/actions/recaps';
import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles';
import {fetchTeamScheduledPosts} from 'mattermost-redux/actions/scheduled_posts';
import {batchFetchStatusesProfilesGroupsFromPosts} from 'mattermost-redux/actions/status_profile_polling';
@ -664,6 +665,9 @@ export function handleEvent(msg) {
case SocketEvents.CONTENT_FLAGGING_REPORT_VALUE_CHANGED:
dispatch(handleContentFlaggingReportValueChanged(msg));
break;
case SocketEvents.RECAP_UPDATED:
dispatch(handleRecapUpdated(msg));
break;
default:
}
@ -2001,3 +2005,12 @@ export function handleContentFlaggingReportValueChanged(msg) {
data: msg.data,
};
}
export function handleRecapUpdated(msg) {
const recapId = msg.data.recap_id;
return async (doDispatch) => {
// Fetch the updated recap and dispatch to Redux
doDispatch(getRecap(recapId));
};
}

View file

@ -29,6 +29,13 @@ const Drafts = makeAsyncComponent('Drafts', lazy(() => import('components/drafts
</div>
),
);
const Recaps = makeAsyncComponent('Recaps', lazy(() => import('components/recaps')),
(
<div className='app__content'>
<LoadingScreen/>
</div>
),
);
const PermalinkView = makeAsyncComponent('PermalinkView', lazy(() => import('components/permalink_view')));
const PlaybookRunner = makeAsyncComponent('PlaybookRunner', lazy(() => import('components/channel_layout/playbook_runner')));
@ -103,6 +110,10 @@ export default class CenterChannel extends React.PureComponent<Props, State> {
component={GlobalThreads}
/>
) : null}
<Route
path={`/:team(${TEAM_NAME_PATH_PATTERN})/recaps`}
component={Recaps}
/>
<Route
path={`/:team(${TEAM_NAME_PATH_PATTERN})/drafts`}
component={Drafts}

View file

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

View file

@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.pagination-dots {
display: flex;
gap: 8px;
.pagination-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(var(--center-channel-color-rgb), 0.16);
transition: background 0.2s ease;
&.active {
background: var(--button-bg);
}
}
}

View file

@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import './pagination_dots.scss';
type Props = {
totalSteps: number;
currentStep: number;
};
const PaginationDots = ({totalSteps, currentStep}: Props) => {
return (
<div className='pagination-dots'>
{Array.from({length: totalSteps}).map((_, index) => (
<div
key={index}
className={`pagination-dot ${index + 1 === currentStep ? 'active' : ''}`}
/>
))}
</div>
);
};
export default PaginationDots;

View file

@ -0,0 +1,290 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import ChannelSelector from './channel_selector';
describe('ChannelSelector', () => {
const mockChannels: Channel[] = [
{
id: 'channel1',
name: 'town-square',
display_name: 'Town Square',
type: 'O',
create_at: 1000,
update_at: 1000,
delete_at: 0,
team_id: 'team1',
creator_id: 'user1',
} as Channel,
{
id: 'channel2',
name: 'off-topic',
display_name: 'Off-Topic',
type: 'O',
create_at: 2000,
update_at: 2000,
delete_at: 0,
team_id: 'team1',
creator_id: 'user1',
} as Channel,
{
id: 'channel3',
name: 'private-channel',
display_name: 'Private Channel',
type: 'P',
create_at: 3000,
update_at: 3000,
delete_at: 0,
team_id: 'team1',
creator_id: 'user1',
} as Channel,
{
id: 'channel4',
name: 'dev-team',
display_name: 'Dev Team',
type: 'O',
create_at: 4000,
update_at: 4000,
delete_at: 0,
team_id: 'team1',
creator_id: 'user1',
} as Channel,
];
const mockUnreadChannels: Channel[] = [mockChannels[0], mockChannels[2]];
const defaultProps = {
selectedChannelIds: [],
setSelectedChannelIds: jest.fn(),
myChannels: mockChannels,
unreadChannels: mockUnreadChannels,
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render the component with label', () => {
renderWithContext(<ChannelSelector {...defaultProps}/>);
expect(screen.getByText('Select the channels you want to include')).toBeInTheDocument();
});
it('should render search input', () => {
renderWithContext(<ChannelSelector {...defaultProps}/>);
expect(screen.getByPlaceholderText('Search and select channels')).toBeInTheDocument();
});
it('should render all channels', () => {
renderWithContext(<ChannelSelector {...defaultProps}/>);
expect(screen.getByText('Town Square')).toBeInTheDocument();
expect(screen.getByText('Off-Topic')).toBeInTheDocument();
expect(screen.getByText('Private Channel')).toBeInTheDocument();
expect(screen.getByText('Dev Team')).toBeInTheDocument();
});
});
describe('Channel Groups', () => {
it('should show recommended channels group with unread channels', () => {
renderWithContext(<ChannelSelector {...defaultProps}/>);
expect(screen.getByText('RECOMMENDED')).toBeInTheDocument();
expect(screen.getByText('Town Square')).toBeInTheDocument();
expect(screen.getByText('Private Channel')).toBeInTheDocument();
});
it('should show all channels group', () => {
renderWithContext(<ChannelSelector {...defaultProps}/>);
expect(screen.getByText('ALL CHANNELS')).toBeInTheDocument();
});
it('should limit recommended channels to 5', () => {
const manyUnreadChannels: Channel[] = Array.from({length: 10}, (_, i) => ({
id: `channel${i}`,
name: `channel-${i}`,
display_name: `Channel ${i}`,
type: 'O',
create_at: i * 1000,
update_at: i * 1000,
delete_at: 0,
team_id: 'team1',
creator_id: 'user1',
} as Channel));
renderWithContext(
<ChannelSelector
{...defaultProps}
myChannels={manyUnreadChannels}
unreadChannels={manyUnreadChannels}
/>,
);
const recommendedSection = screen.getByText('RECOMMENDED').parentElement;
const channelItems = recommendedSection?.querySelectorAll('.channel-selector-item');
expect(channelItems?.length).toBeLessThanOrEqual(5);
});
});
describe('Search Functionality', () => {
it('should filter channels by display name', async () => {
renderWithContext(<ChannelSelector {...defaultProps}/>);
const searchInput = screen.getByPlaceholderText('Search and select channels');
await userEvent.type(searchInput, 'Town');
expect(screen.getByText('Town Square')).toBeInTheDocument();
expect(screen.queryByText('Off-Topic')).not.toBeInTheDocument();
});
it('should filter channels by channel name', async () => {
renderWithContext(<ChannelSelector {...defaultProps}/>);
const searchInput = screen.getByPlaceholderText('Search and select channels');
await userEvent.type(searchInput, 'off-topic');
expect(screen.getByText('Off-Topic')).toBeInTheDocument();
expect(screen.queryByText('Town Square')).not.toBeInTheDocument();
});
it('should be case insensitive', async () => {
renderWithContext(<ChannelSelector {...defaultProps}/>);
const searchInput = screen.getByPlaceholderText('Search and select channels');
await userEvent.type(searchInput, 'PRIVATE');
expect(screen.getByText('Private Channel')).toBeInTheDocument();
});
it('should show empty state when no channels match search', async () => {
renderWithContext(<ChannelSelector {...defaultProps}/>);
const searchInput = screen.getByPlaceholderText('Search and select channels');
await userEvent.type(searchInput, 'nonexistent');
expect(screen.getByText('No channels found')).toBeInTheDocument();
});
it('should update recommended channels based on search', async () => {
renderWithContext(<ChannelSelector {...defaultProps}/>);
const searchInput = screen.getByPlaceholderText('Search and select channels');
await userEvent.type(searchInput, 'Private');
// Only Private Channel should be visible, and it should be in recommended
expect(screen.getByText('Private Channel')).toBeInTheDocument();
expect(screen.queryByText('Town Square')).not.toBeInTheDocument();
});
});
describe('Channel Selection', () => {
it('should show checkbox for each channel', () => {
renderWithContext(<ChannelSelector {...defaultProps}/>);
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes.length).toBe(mockChannels.length);
});
it('should check checkbox for selected channels', () => {
renderWithContext(
<ChannelSelector
{...defaultProps}
selectedChannelIds={['channel1', 'channel2']}
/>,
);
const checkboxes = screen.getAllByRole('checkbox');
const checkedBoxes = checkboxes.filter((cb) => (cb as HTMLInputElement).checked);
expect(checkedBoxes.length).toBe(2);
});
it('should call setSelectedChannelIds when channel is clicked', async () => {
const setSelectedChannelIds = jest.fn();
renderWithContext(
<ChannelSelector
{...defaultProps}
setSelectedChannelIds={setSelectedChannelIds}
/>,
);
const channelItem = screen.getByText('Town Square').closest('.channel-selector-item');
await userEvent.click(channelItem!);
expect(setSelectedChannelIds).toHaveBeenCalledWith(['channel1']);
});
it('should add channel to selection when unselected channel is clicked', async () => {
const setSelectedChannelIds = jest.fn();
renderWithContext(
<ChannelSelector
{...defaultProps}
selectedChannelIds={['channel1']}
setSelectedChannelIds={setSelectedChannelIds}
/>,
);
const channelItem = screen.getByText('Off-Topic').closest('.channel-selector-item');
await userEvent.click(channelItem!);
expect(setSelectedChannelIds).toHaveBeenCalledWith(['channel1', 'channel2']);
});
it('should remove channel from selection when selected channel is clicked', async () => {
const setSelectedChannelIds = jest.fn();
renderWithContext(
<ChannelSelector
{...defaultProps}
selectedChannelIds={['channel1', 'channel2']}
setSelectedChannelIds={setSelectedChannelIds}
/>,
);
const channelItem = screen.getByText('Town Square').closest('.channel-selector-item');
await userEvent.click(channelItem!);
expect(setSelectedChannelIds).toHaveBeenCalledWith(['channel2']);
});
});
describe('Channel Icons', () => {
it('should show globe icon for open channels', () => {
renderWithContext(<ChannelSelector {...defaultProps}/>);
const townSquareItem = screen.getByText('Town Square').closest('.channel-selector-item');
const icon = townSquareItem?.querySelector('.icon-globe');
expect(icon).toBeInTheDocument();
});
it('should show lock icon for private channels', () => {
renderWithContext(<ChannelSelector {...defaultProps}/>);
const privateChannelItem = screen.getByText('Private Channel').closest('.channel-selector-item');
const icon = privateChannelItem?.querySelector('.icon-lock-outline');
expect(icon).toBeInTheDocument();
});
});
describe('Empty State', () => {
it('should show empty state when no channels available', () => {
renderWithContext(
<ChannelSelector
{...defaultProps}
myChannels={[]}
unreadChannels={[]}
/>,
);
expect(screen.getByText('No channels found')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,141 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useMemo} from 'react';
import {useIntl} from 'react-intl';
import type {Channel} from '@mattermost/types/channels';
import Input from 'components/widgets/inputs/input/input';
import {Constants} from 'utils/constants';
type Props = {
selectedChannelIds: string[];
setSelectedChannelIds: (ids: string[]) => void;
myChannels: Channel[];
unreadChannels: Channel[];
};
const ChannelSelector = ({selectedChannelIds, setSelectedChannelIds, myChannels, unreadChannels}: Props) => {
const {formatMessage} = useIntl();
const [searchTerm, setSearchTerm] = useState('');
const toggleChannel = (channelId: string) => {
if (selectedChannelIds.includes(channelId)) {
setSelectedChannelIds(selectedChannelIds.filter((id) => id !== channelId));
} else {
setSelectedChannelIds([...selectedChannelIds, channelId]);
}
};
const filteredChannels = useMemo(() => {
const term = searchTerm.toLowerCase();
return myChannels.filter((channel) => {
return channel.display_name.toLowerCase().includes(term) ||
channel.name.toLowerCase().includes(term);
});
}, [myChannels, searchTerm]);
const recommendedChannels = useMemo(() => {
// Recommended channels are unread channels that match the search
return filteredChannels.filter((channel) =>
unreadChannels.some((uc) => uc.id === channel.id),
).slice(0, 5);
}, [filteredChannels, unreadChannels]);
const otherChannels = useMemo(() => {
const recommendedIds = recommendedChannels.map((c) => c.id);
return filteredChannels.filter((channel) => !recommendedIds.includes(channel.id));
}, [filteredChannels, recommendedChannels]);
const getChannelIcon = (channel: Channel) => {
switch (channel.type) {
case Constants.OPEN_CHANNEL:
return 'icon-globe';
case Constants.PRIVATE_CHANNEL:
return 'icon-lock-outline';
case Constants.GM_CHANNEL:
return 'icon-account-multiple-outline';
case Constants.DM_CHANNEL:
return 'icon-account-outline';
default:
return 'icon-globe';
}
};
const renderChannelItem = (channel: Channel) => {
const isSelected = selectedChannelIds.includes(channel.id);
return (
<div
key={channel.id}
className='channel-selector-item'
onClick={() => toggleChannel(channel.id)}
>
<div className='channel-selector-checkbox'>
<input
type='checkbox'
checked={isSelected}
onChange={() => {}} // Handled by parent div
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className='channel-selector-channel-info'>
<i className={`icon ${getChannelIcon(channel)}`}/>
<span className='channel-name'>{channel.display_name}</span>
</div>
</div>
);
};
return (
<div className='step-two-channel-selector'>
<label className='form-label'>
{formatMessage({id: 'recaps.modal.selectChannels', defaultMessage: 'Select the channels you want to include'})}
</label>
<div className='channel-selector-container'>
<div className='channel-selector-search'>
<Input
type='text'
placeholder={{id: 'recaps.modal.searchChannels', defaultMessage: 'Search and select channels'}}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
useLegend={false}
containerClassName='channel-selector-input-container'
inputPrefix={<i className='icon icon-magnify'/>}
/>
</div>
<div className='channel-selector-list'>
{recommendedChannels.length > 0 && (
<div className='channel-group'>
<div className='channel-group-title'>
{formatMessage({id: 'recaps.modal.recommended', defaultMessage: 'RECOMMENDED'})}
</div>
{recommendedChannels.map(renderChannelItem)}
</div>
)}
{otherChannels.length > 0 && (
<div className='channel-group'>
<div className='channel-group-title'>
{formatMessage({id: 'recaps.modal.allChannels', defaultMessage: 'ALL CHANNELS'})}
</div>
{otherChannels.map(renderChannelItem)}
</div>
)}
{filteredChannels.length === 0 && (
<div className='channel-selector-empty'>
{formatMessage({id: 'recaps.modal.noChannels', defaultMessage: 'No channels found'})}
</div>
)}
</div>
</div>
</div>
);
};
export default ChannelSelector;

View file

@ -0,0 +1,288 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import ChannelSummary from './channel_summary';
describe('ChannelSummary', () => {
const mockChannels: Channel[] = [
{
id: 'channel1',
name: 'town-square',
display_name: 'Town Square',
type: 'O',
create_at: 1000,
update_at: 1000,
delete_at: 0,
team_id: 'team1',
creator_id: 'user1',
} as Channel,
{
id: 'channel2',
name: 'off-topic',
display_name: 'Off-Topic',
type: 'O',
create_at: 2000,
update_at: 2000,
delete_at: 0,
team_id: 'team1',
creator_id: 'user1',
} as Channel,
{
id: 'channel3',
name: 'private-channel',
display_name: 'Private Channel',
type: 'P',
create_at: 3000,
update_at: 3000,
delete_at: 0,
team_id: 'team1',
creator_id: 'user1',
} as Channel,
{
id: 'channel4',
name: 'group-message',
display_name: 'Group Message',
type: 'G',
create_at: 4000,
update_at: 4000,
delete_at: 0,
team_id: 'team1',
creator_id: 'user1',
} as Channel,
{
id: 'channel5',
name: 'direct-message',
display_name: 'Direct Message',
type: 'D',
create_at: 5000,
update_at: 5000,
delete_at: 0,
team_id: 'team1',
creator_id: 'user1',
} as Channel,
];
const defaultProps = {
selectedChannelIds: ['channel1', 'channel2'],
myChannels: mockChannels,
};
describe('Rendering', () => {
it('should render the component with title', () => {
renderWithContext(<ChannelSummary {...defaultProps}/>);
expect(screen.getByText('The following channels will be included in your recap')).toBeInTheDocument();
});
it('should render selected channels only', () => {
renderWithContext(<ChannelSummary {...defaultProps}/>);
expect(screen.getByText('Town Square')).toBeInTheDocument();
expect(screen.getByText('Off-Topic')).toBeInTheDocument();
expect(screen.queryByText('Private Channel')).not.toBeInTheDocument();
});
it('should render all selected channels when multiple are selected', () => {
renderWithContext(
<ChannelSummary
{...defaultProps}
selectedChannelIds={['channel1', 'channel2', 'channel3']}
/>,
);
expect(screen.getByText('Town Square')).toBeInTheDocument();
expect(screen.getByText('Off-Topic')).toBeInTheDocument();
expect(screen.getByText('Private Channel')).toBeInTheDocument();
});
it('should not render any channels when none are selected', () => {
renderWithContext(
<ChannelSummary
{...defaultProps}
selectedChannelIds={[]}
/>,
);
expect(screen.queryByText('Town Square')).not.toBeInTheDocument();
expect(screen.queryByText('Off-Topic')).not.toBeInTheDocument();
});
});
describe('Channel Icons', () => {
it('should show globe icon for open channels', () => {
renderWithContext(
<ChannelSummary
{...defaultProps}
selectedChannelIds={['channel1']}
/>,
);
const channelItem = screen.getByText('Town Square').closest('.summary-channel-item');
const icon = channelItem?.querySelector('.icon-globe');
expect(icon).toBeInTheDocument();
});
it('should show lock icon for private channels', () => {
renderWithContext(
<ChannelSummary
{...defaultProps}
selectedChannelIds={['channel3']}
/>,
);
const channelItem = screen.getByText('Private Channel').closest('.summary-channel-item');
const icon = channelItem?.querySelector('.icon-lock-outline');
expect(icon).toBeInTheDocument();
});
it('should show group icon for group messages', () => {
renderWithContext(
<ChannelSummary
{...defaultProps}
selectedChannelIds={['channel4']}
/>,
);
const channelItem = screen.getByText('Group Message').closest('.summary-channel-item');
const icon = channelItem?.querySelector('.icon-account-multiple-outline');
expect(icon).toBeInTheDocument();
});
it('should show account icon for direct messages', () => {
renderWithContext(
<ChannelSummary
{...defaultProps}
selectedChannelIds={['channel5']}
/>,
);
const channelItem = screen.getByText('Direct Message').closest('.summary-channel-item');
const icon = channelItem?.querySelector('.icon-account-outline');
expect(icon).toBeInTheDocument();
});
it('should default to globe icon for unknown channel types', () => {
const unknownChannel: Channel = {
id: 'channel6',
name: 'unknown',
display_name: 'Unknown Channel',
type: 'X' as any,
create_at: 6000,
update_at: 6000,
delete_at: 0,
team_id: 'team1',
creator_id: 'user1',
} as Channel;
renderWithContext(
<ChannelSummary
selectedChannelIds={['channel6']}
myChannels={[...mockChannels, unknownChannel]}
/>,
);
const channelItem = screen.getByText('Unknown Channel').closest('.summary-channel-item');
const icon = channelItem?.querySelector('.icon-globe');
expect(icon).toBeInTheDocument();
});
});
describe('Channel Filtering', () => {
it('should only show channels that are both selected and in myChannels', () => {
renderWithContext(
<ChannelSummary
selectedChannelIds={['channel1', 'channel999']}
myChannels={mockChannels}
/>,
);
expect(screen.getByText('Town Square')).toBeInTheDocument();
expect(screen.queryByText('channel999')).not.toBeInTheDocument();
});
it('should handle empty myChannels array', () => {
renderWithContext(
<ChannelSummary
selectedChannelIds={['channel1', 'channel2']}
myChannels={[]}
/>,
);
expect(screen.queryByText('Town Square')).not.toBeInTheDocument();
expect(screen.queryByText('Off-Topic')).not.toBeInTheDocument();
});
it('should maintain order of channels based on myChannels array', () => {
renderWithContext(
<ChannelSummary
{...defaultProps}
selectedChannelIds={['channel3', 'channel1', 'channel2']}
/>,
);
const channelItems = screen.getAllByRole('generic').filter(
(el) => el.className === 'summary-channel-item',
);
// Channels should appear in the order they appear in myChannels (channel1, channel2, channel3)
expect(channelItems[0]).toHaveTextContent('Town Square');
expect(channelItems[1]).toHaveTextContent('Off-Topic');
expect(channelItems[2]).toHaveTextContent('Private Channel');
});
});
describe('Display Names', () => {
it('should display channel display_name not name', () => {
renderWithContext(<ChannelSummary {...defaultProps}/>);
expect(screen.getByText('Town Square')).toBeInTheDocument();
expect(screen.queryByText('town-square')).not.toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle duplicate channel IDs in selectedChannelIds', () => {
renderWithContext(
<ChannelSummary
selectedChannelIds={['channel1', 'channel1', 'channel2']}
myChannels={mockChannels}
/>,
);
const townSquareItems = screen.getAllByText('Town Square');
// Should only render each channel once even if ID appears multiple times
expect(townSquareItems.length).toBe(1);
});
it('should handle very long channel names', () => {
const longNameChannel: Channel = {
id: 'channel-long',
name: 'long-channel-name',
display_name: 'A'.repeat(100),
type: 'O',
create_at: 6000,
update_at: 6000,
delete_at: 0,
team_id: 'team1',
creator_id: 'user1',
} as Channel;
renderWithContext(
<ChannelSummary
selectedChannelIds={['channel-long']}
myChannels={[longNameChannel]}
/>,
);
expect(screen.getByText('A'.repeat(100))).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import type {Channel} from '@mattermost/types/channels';
import {Constants} from 'utils/constants';
type Props = {
selectedChannelIds: string[];
myChannels: Channel[];
};
const ChannelSummary = ({selectedChannelIds, myChannels}: Props) => {
const {formatMessage} = useIntl();
const selectedChannels = myChannels.filter((channel) =>
selectedChannelIds.includes(channel.id),
);
const getChannelIcon = (channel: Channel) => {
switch (channel.type) {
case Constants.OPEN_CHANNEL:
return 'icon-globe';
case Constants.PRIVATE_CHANNEL:
return 'icon-lock-outline';
case Constants.GM_CHANNEL:
return 'icon-account-multiple-outline';
case Constants.DM_CHANNEL:
return 'icon-account-outline';
default:
return 'icon-globe';
}
};
return (
<div className='step-two-summary'>
<label className='form-label'>
{formatMessage({id: 'recaps.modal.summaryTitle', defaultMessage: 'The following channels will be included in your recap'})}
</label>
<div className='summary-channels-list'>
{selectedChannels.map((channel) => (
<div
key={channel.id}
className='summary-channel-item'
>
<i className={`icon ${getChannelIcon(channel)}`}/>
<span className='channel-name'>{channel.display_name}</span>
</div>
))}
</div>
</div>
);
};
export default ChannelSummary;

View file

@ -0,0 +1,389 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.create-recap-modal {
.modal-content {
width: 600px;
min-height: 560px; // Prevents height shifting between steps (matches channel selector step)
}
.modal-body {
min-height: 440px; // Ensures footer stays at bottom (560px modal - 60px header - 60px footer)
}
.create-recap-modal-body {
max-height: 500px;
padding: 0px 0px;
overflow-y: auto;
}
.create-recap-modal-header {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
.create-recap-modal-header-actions {
display: flex;
align-items: center;
}
}
.create-recap-modal-error {
display: flex;
align-items: center;
padding: 12px 16px;
border: 1px solid rgba(var(--error-text-rgb), 0.24);
border-radius: 4px;
margin-bottom: 16px;
background-color: rgba(var(--error-text-rgb), 0.08);
color: var(--error-text);
font-size: 14px;
gap: 8px;
.icon {
flex-shrink: 0;
font-size: 16px;
}
}
.create-recap-modal-footer {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
&-left {
display: flex;
flex: 1;
align-items: center;
gap: 12px;
.btn-link {
display: flex;
align-items: center;
padding: 0;
border: none;
background: transparent;
color: var(--button-bg);
font-size: 14px;
font-weight: 600;
gap: 6px;
&:hover:not(:disabled) {
text-decoration: underline;
}
&:disabled {
cursor: not-allowed;
opacity: 0.4;
}
.icon {
font-size: 16px;
}
}
}
&-actions {
display: flex;
align-items: center;
gap: 8px;
.btn {
display: flex;
align-items: center;
gap: 6px;
.icon {
font-size: 16px;
}
}
}
}
// Step One Styles
.step-one {
display: flex;
flex-direction: column;
gap: 0;
.form-group {
display: flex;
flex-direction: column;
.form-label {
margin-bottom: 0;
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
}
&.name-input-group {
margin-bottom: 28px;
.form-label {
padding-bottom: 8px;
}
}
&.type-selection-group {
.form-label {
margin-bottom: 12px;
}
}
.input-container {
position: relative;
display: flex;
align-items: center;
.icon {
position: absolute;
left: 16px;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 16px;
}
.form-control {
height: 40px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
&:focus {
border-color: var(--button-bg);
}
}
}
}
.recap-type-options {
display: flex;
flex-direction: column;
gap: 12px;
.recap-type-card {
position: relative;
display: flex;
align-items: center;
padding: 12px 20px 12px 12px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
background-color: var(--center-channel-bg);
cursor: pointer;
gap: 12px;
text-align: left;
transition: all 0.2s;
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.04);
}
&.selected {
border-color: var(--button-bg);
background-color: rgba(var(--button-bg-rgb), 0.04);
.recap-type-card-icon {
background-color: rgba(var(--button-bg-rgb), 0.08);
.icon {
color: var(--button-bg);
}
}
}
&.disabled {
cursor: not-allowed;
opacity: 0.5;
&:hover {
background-color: var(--center-channel-bg);
}
}
.recap-type-card-icon {
display: flex;
width: 40px;
height: 40px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 20px;
background-color: rgba(var(--center-channel-color-rgb), 0.08);
.icon {
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 24px;
}
}
.recap-type-card-content {
display: flex;
flex: 1;
flex-direction: column;
padding: 1px 0 3px;
gap: 2px;
.recap-type-card-title {
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
.recap-type-card-description {
color: rgba(var(--center-channel-color-rgb), 0.72);
font-size: 12px;
line-height: 16px;
}
}
.selected-icon {
color: var(--button-bg);
font-size: 20px;
}
}
}
}
// Step Two Channel Selector Styles
.step-two-channel-selector {
display: flex;
flex-direction: column;
gap: 16px;
.form-label {
margin-bottom: 0;
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
}
.channel-selector-container {
display: flex;
overflow: hidden;
max-height: 400px;
flex-direction: column;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
border-radius: 4px;
}
.channel-selector-search {
position: relative;
padding: 8px 12px 12px;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
background-color: var(--center-channel-bg);
.Input {
border: none !important;
}
}
.channel-selector-list {
flex: 1;
background-color: var(--center-channel-bg);
overflow-y: auto;
}
.channel-group {
.channel-group-title {
padding: 6px 20px;
background-color: var(--center-channel-bg);
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.24px;
text-transform: uppercase;
}
}
.channel-selector-item {
display: flex;
align-items: center;
padding: 6px 20px 6px 16px;
background-color: var(--center-channel-bg);
cursor: pointer;
gap: 8px;
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.04);
}
.channel-selector-checkbox {
padding: 4px;
input[type='checkbox'] {
width: 12px;
height: 12px;
cursor: pointer;
}
}
.channel-selector-channel-info {
display: flex;
flex: 1;
align-items: center;
padding: 2px 4px;
gap: 8px;
.icon {
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 16px;
}
.channel-name {
color: var(--center-channel-color);
font-size: 14px;
line-height: 20px;
}
}
}
.channel-selector-empty {
padding: 40px 20px;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 14px;
text-align: center;
}
}
// Step Two Summary Styles
.step-two-summary {
display: flex;
flex-direction: column;
gap: 16px;
.form-label {
margin-bottom: 0;
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
}
.summary-channels-list {
max-height: 300px;
overflow-y: auto;
}
.summary-channel-item {
display: flex;
align-items: center;
padding: 6px 16px;
margin-left: -16px;
gap: 8px;
.icon {
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 16px;
}
.channel-name {
color: var(--center-channel-color);
font-size: 14px;
line-height: 20px;
}
}
}
}

View file

@ -0,0 +1,306 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {getAgents} from 'mattermost-redux/actions/agents';
import {renderWithContext, screen, userEvent, waitFor, waitForElementToBeRemoved} from 'tests/react_testing_utils';
import CreateRecapModal from './create_recap_modal';
jest.mock('mattermost-redux/actions/recaps', () => ({
createRecap: jest.fn(() => ({type: 'CREATE_RECAP'})),
}));
jest.mock('mattermost-redux/actions/agents', () => ({
getAgents: jest.fn(() => ({type: 'GET_AGENTS'})),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: () => ({
push: jest.fn(),
}),
useRouteMatch: () => ({
url: '/team/test',
}),
}));
describe('CreateRecapModal', () => {
const defaultProps = {
onExited: jest.fn(),
};
const mockAgents = [
{
id: 'copilot-bot',
displayName: 'Copilot',
username: 'copilot',
service_id: 'copilot-service',
service_type: 'copilot',
},
{
id: 'openai-bot',
displayName: 'OpenAI',
username: 'openai',
service_id: 'openai-service',
service_type: 'openai',
},
{
id: 'azure-bot',
displayName: 'Azure OpenAI',
username: 'azureopenai',
service_id: 'azure-service',
service_type: 'azure',
},
];
const initialState = {
entities: {
users: {
currentUserId: 'user1',
profiles: {
user1: {id: 'user1', username: 'testuser'},
},
},
teams: {
currentTeamId: 'team1',
teams: {
team1: {id: 'team1', name: 'test-team', display_name: 'Test Team'},
},
myMembers: {
team1: {team_id: 'team1', user_id: 'user1'},
},
},
channels: {
channels: {
channel1: {id: 'channel1', name: 'test-channel', display_name: 'Test Channel', team_id: 'team1'},
channel2: {id: 'channel2', name: 'another-channel', display_name: 'Another Channel', team_id: 'team1'},
},
channelsInTeam: {
team1: new Set(['channel1', 'channel2']),
},
myMembers: {
channel1: {channel_id: 'channel1', msg_count: 5, mention_count: 0},
channel2: {channel_id: 'channel2', msg_count: 3, mention_count: 0},
},
messageCounts: {
channel1: {total: 10, root: 10},
channel2: {total: 5, root: 5},
},
},
agents: {
agents: mockAgents,
},
preferences: {
myPreferences: {},
},
general: {
config: {},
},
},
views: {
channel: {
postVisibility: {},
lastChannelViewTime: {},
loadingPosts: {},
focusedPostId: '',
mobileView: false,
lastUnreadChannel: null,
lastGetPosts: {},
channelPrefetchStatus: {},
toastStatus: false,
},
},
};
beforeEach(() => {
jest.clearAllMocks();
});
test('should render modal with header including AI agent dropdown', () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
expect(screen.getByText('Set up your recap')).toBeInTheDocument();
expect(screen.getByText('GENERATE WITH:')).toBeInTheDocument();
expect(screen.getByText('Copilot')).toBeInTheDocument();
});
test('should fetch AI agents on mount', () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
expect(getAgents).toHaveBeenCalledTimes(1);
});
test('should show AI agent dropdown with default bot selected and label', () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
// The default bot (Copilot) should be displayed
expect(screen.getByText('Copilot')).toBeInTheDocument();
// Label should be shown
expect(screen.getByText('GENERATE WITH:')).toBeInTheDocument();
});
test('should open AI agent dropdown and show bot options', async () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
const dropdownButton = screen.getByLabelText('Agent selector');
await userEvent.click(dropdownButton);
expect(screen.getByText('CHOOSE A BOT')).toBeInTheDocument();
expect(screen.getByText('Copilot (default)')).toBeInTheDocument();
expect(screen.getByText('OpenAI')).toBeInTheDocument();
expect(screen.getByText('Azure OpenAI')).toBeInTheDocument();
});
test('should change selected bot when clicking on a different bot', async () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
// Wait for initial bot to be selected
await waitFor(() => {
const dropdownButton = screen.getByLabelText('Agent selector');
expect(dropdownButton).toHaveTextContent('Copilot');
});
// Open dropdown
const dropdownButton = screen.getByLabelText('Agent selector');
await userEvent.click(dropdownButton);
// Click on OpenAI
const openAIOption = screen.getByText('OpenAI');
await userEvent.click(openAIOption);
// Wait for menu to close
await waitForElementToBeRemoved(() => screen.queryByText('CHOOSE A BOT'));
// OpenAI should now be displayed in the button
expect(screen.getByText('OpenAI')).toBeInTheDocument();
});
test('should disable AI agent dropdown when submitting', async () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
// Fill in the form to enable submit
const nameInput = screen.getByPlaceholderText('Give your recap a name');
await userEvent.type(nameInput, 'Test Recap');
// Select all unreads option
const allUnreadsButton = screen.getByText('Recap all my unreads');
await userEvent.click(allUnreadsButton);
// Go to next step
const nextButton = screen.getByRole('button', {name: /next/i});
await userEvent.click(nextButton);
// The dropdown button should still be enabled before submission
const dropdownButton = screen.getByLabelText('Agent selector');
expect(dropdownButton).not.toBeDisabled();
});
test('should render step one initially', () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
expect(screen.getByText('Give your recap a name')).toBeInTheDocument();
expect(screen.getByText('What type of recap would you like?')).toBeInTheDocument();
});
test('should show pagination dots', () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
const paginationDots = document.querySelectorAll('.pagination-dot');
expect(paginationDots.length).toBeGreaterThan(0);
});
test('should show Next button on first step', () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
expect(screen.getByRole('button', {name: /next/i})).toBeInTheDocument();
expect(screen.queryByRole('button', {name: /previous/i})).not.toBeInTheDocument();
});
test('should disable Next button when form is incomplete', () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
const nextButton = screen.getByRole('button', {name: /next/i});
expect(nextButton).toBeDisabled();
});
test('should enable Next button when form is complete', async () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
// Wait for bot to be selected automatically
await waitFor(() => {
const dropdownButton = screen.getByLabelText('Agent selector');
expect(dropdownButton).toHaveTextContent('Copilot');
});
const nameInput = screen.getByPlaceholderText('Give your recap a name');
await userEvent.type(nameInput, 'Test Recap');
const allUnreadsButton = screen.getByText('Recap all my unreads');
await userEvent.click(allUnreadsButton);
const nextButton = screen.getByRole('button', {name: /next/i});
await waitFor(() => expect(nextButton).not.toBeDisabled());
});
test('should show Previous button on later steps', async () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
// Fill form to enable navigation
await waitFor(() => {
const dropdownButton = screen.getByLabelText('Agent selector');
expect(dropdownButton).toHaveTextContent('Copilot');
});
const nameInput = screen.getByPlaceholderText('Give your recap a name');
await userEvent.type(nameInput, 'Test Recap');
// Select "Recap selected channels" to go to step 2
const selectedChannelsButton = screen.getByText('Recap selected channels');
await userEvent.click(selectedChannelsButton);
const nextButton = screen.getByRole('button', {name: /next/i});
await userEvent.click(nextButton);
// Now we should be on step 2 and see the Previous button
await waitFor(() => {
expect(screen.getByRole('button', {name: /previous/i})).toBeInTheDocument();
});
});
test('should maintain selected bot across step navigation', async () => {
renderWithContext(<CreateRecapModal {...defaultProps}/>, initialState);
// Wait for initial bot to be selected
await waitFor(() => {
const dropdownButton = screen.getByLabelText('Agent selector');
expect(dropdownButton).toHaveTextContent('Copilot');
});
// Change bot to OpenAI
const dropdownButton = screen.getByLabelText('Agent selector');
await userEvent.click(dropdownButton);
const openAIOption = screen.getByText('OpenAI');
await userEvent.click(openAIOption);
// Wait for menu to close
await waitForElementToBeRemoved(() => screen.queryByText('CHOOSE A BOT'));
// Fill form and go to next step
const nameInput = screen.getByPlaceholderText('Give your recap a name');
await userEvent.type(nameInput, 'Test Recap');
const allUnreadsButton = screen.getByText('Recap all my unreads');
await userEvent.click(allUnreadsButton);
const nextButton = screen.getByRole('button', {name: /next/i});
await userEvent.click(nextButton);
// OpenAI should still be selected in the header
expect(screen.getByText('OpenAI')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,271 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useCallback, useEffect} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {useHistory, useRouteMatch} from 'react-router-dom';
import {ChevronLeftIcon, ChevronRightIcon} from '@mattermost/compass-icons/components';
import {GenericModal} from '@mattermost/components';
import type {Channel} from '@mattermost/types/channels';
import {getAgents} from 'mattermost-redux/actions/agents';
import {createRecap} from 'mattermost-redux/actions/recaps';
import {getAgents as getAgentsSelector} from 'mattermost-redux/selectors/entities/agents';
import {getMyChannels, getUnreadChannelIds} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {AgentDropdown} from 'components/common/agents';
import PaginationDots from 'components/common/pagination_dots';
import ChannelSelector from './channel_selector';
import ChannelSummary from './channel_summary';
import RecapConfiguration from './recap_configuration';
import './create_recap_modal.scss';
type Props = {
onExited: () => void;
};
type RecapType = 'selected' | 'all_unreads';
const CreateRecapModal = ({onExited}: Props) => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const history = useHistory();
const {url} = useRouteMatch();
const currentUserId = useSelector(getCurrentUserId);
const myChannels = useSelector(getMyChannels);
const unreadChannelIds = useSelector(getUnreadChannelIds);
const agents = useSelector(getAgentsSelector);
const [currentStep, setCurrentStep] = useState(1);
const [recapName, setRecapName] = useState('');
const [recapType, setRecapType] = useState<RecapType | null>(null);
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]);
const [selectedBotId, setSelectedBotId] = useState<string>('');
const [isAgentMenuOpen, setIsAgentMenuOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch AI agents on mount
useEffect(() => {
dispatch(getAgents());
}, [dispatch]);
// Set default bot when agents are loaded
useEffect(() => {
if (agents.length > 0 && !selectedBotId) {
setSelectedBotId(agents[0].id);
}
}, [agents, selectedBotId]);
// Get unread channels
const unreadChannels = myChannels.filter((channel: Channel) =>
unreadChannelIds.includes(channel.id),
);
const handleNext = useCallback(() => {
if (currentStep === 1) {
if (recapType === 'all_unreads') {
// For all unreads, skip channel selector and go to summary
setSelectedChannelIds(unreadChannels.map((c: Channel) => c.id));
setCurrentStep(3); // Go to summary
} else {
// For selected channels, go to channel selector
setCurrentStep(2);
}
} else if (currentStep === 2) {
// From channel selector to summary
setCurrentStep(3);
}
}, [currentStep, recapType, unreadChannels]);
const handlePrevious = useCallback(() => {
if (currentStep === 3 && recapType === 'all_unreads') {
// From summary back to step 1 if all unreads
setCurrentStep(1);
} else if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
}, [currentStep, recapType]);
const handleSubmit = useCallback(async () => {
if (selectedChannelIds.length === 0) {
setError(formatMessage({id: 'recaps.modal.error.noChannels', defaultMessage: 'Please select at least one channel.'}));
return;
}
if (!currentUserId) {
return;
}
if (!selectedBotId) {
setError(formatMessage({id: 'recaps.modal.error.noBot', defaultMessage: 'Please select an AI agent.'}));
return;
}
setIsSubmitting(true);
setError(null);
try {
await dispatch(createRecap(recapName, selectedChannelIds, selectedBotId));
onExited();
history.push(`${url}/recaps`);
} catch (err) {
setError(formatMessage({id: 'recaps.modal.error.createFailed', defaultMessage: 'Failed to create recap. Please try again.'}));
setIsSubmitting(false);
}
}, [selectedChannelIds, currentUserId, selectedBotId, dispatch, onExited, history, url, formatMessage, recapName]);
const canProceed = () => {
if (currentStep === 1) {
return recapName.trim().length > 0 && recapType !== null && selectedBotId.length > 0;
} else if (currentStep === 2) {
return selectedChannelIds.length > 0;
} else if (currentStep === 3) {
return selectedChannelIds.length > 0 && selectedBotId.length > 0;
}
return false;
};
const getTotalSteps = () => {
return recapType === 'all_unreads' ? 2 : 3;
};
const getActualStep = () => {
if (recapType === 'all_unreads') {
return currentStep === 1 ? 1 : 2;
}
return currentStep;
};
const renderStep = () => {
switch (currentStep) {
case 1:
return (
<RecapConfiguration
recapName={recapName}
setRecapName={setRecapName}
recapType={recapType}
setRecapType={setRecapType}
unreadChannels={unreadChannels}
/>
);
case 2:
return (
<ChannelSelector
selectedChannelIds={selectedChannelIds}
setSelectedChannelIds={setSelectedChannelIds}
myChannels={myChannels}
unreadChannels={unreadChannels}
/>
);
case 3:
return (
<ChannelSummary
selectedChannelIds={selectedChannelIds}
myChannels={myChannels}
/>
);
default:
return null;
}
};
const confirmButtonText = currentStep === 3 ? formatMessage({id: 'recaps.modal.startRecap', defaultMessage: 'Start recap'}) : formatMessage({id: 'generic_modal.next', defaultMessage: 'Next'});
const handleBotSelect = useCallback((botId: string) => {
setSelectedBotId(botId);
}, []);
const handleAgentMenuToggle = useCallback((isOpen: boolean) => {
setIsAgentMenuOpen(isOpen);
}, []);
const headerText = (
<div className='create-recap-modal-header'>
<span>{formatMessage({id: 'recaps.modal.title', defaultMessage: 'Set up your recap'})}</span>
<div className='create-recap-modal-header-actions'>
<AgentDropdown
showLabel={true}
selectedBotId={selectedBotId}
onBotSelect={handleBotSelect}
bots={agents}
defaultBotId={agents.length > 0 ? agents[0].id : undefined}
disabled={isSubmitting}
onMenuToggle={handleAgentMenuToggle}
/>
</div>
</div>
);
const handleConfirmClick = useCallback(() => {
if (currentStep === 3) {
handleSubmit();
return;
}
handleNext();
}, [currentStep, handleSubmit, handleNext]);
const footerContent = (
<div className='create-recap-modal-footer'>
<div className='create-recap-modal-footer-left'>
<PaginationDots
totalSteps={getTotalSteps()}
currentStep={getActualStep()}
/>
</div>
<div className='create-recap-modal-footer-actions'>
{currentStep > 1 && (
<button
type='button'
className='GenericModal__button btn btn-tertiary'
onClick={handlePrevious}
disabled={isSubmitting}
>
<ChevronLeftIcon size={16}/>
{formatMessage({id: 'generic_modal.previous', defaultMessage: 'Previous'})}
</button>
)}
<button
type='submit'
className='GenericModal__button btn btn-primary'
onClick={handleConfirmClick}
disabled={!canProceed() || isSubmitting}
>
{confirmButtonText}
{currentStep < 3 && <ChevronRightIcon size={16}/>}
</button>
</div>
</div>
);
return (
<GenericModal
className='create-recap-modal'
id='createRecapModal'
onExited={onExited}
modalHeaderText={headerText}
enforceFocus={!isAgentMenuOpen}
compassDesign={true}
footerDivider={false}
footerContent={footerContent}
>
<div className='create-recap-modal-body'>
{error && (
<div className='create-recap-modal-error'>
<i className='icon icon-alert-circle-outline'/>
<span>{error}</span>
</div>
)}
{renderStep()}
</div>
</GenericModal>
);
};
export default CreateRecapModal;

View file

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

View file

@ -0,0 +1,250 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import RecapConfiguration from './recap_configuration';
describe('RecapConfiguration', () => {
const mockUnreadChannels: Channel[] = [
{
id: 'channel1',
name: 'channel-1',
display_name: 'Channel 1',
type: 'O',
} as Channel,
{
id: 'channel2',
name: 'channel-2',
display_name: 'Channel 2',
type: 'P',
} as Channel,
];
const defaultProps = {
recapName: '',
setRecapName: jest.fn(),
recapType: null as 'selected' | 'all_unreads' | null,
setRecapType: jest.fn(),
unreadChannels: mockUnreadChannels,
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('Recap Name Input', () => {
it('should render name input field', () => {
renderWithContext(<RecapConfiguration {...defaultProps}/>);
expect(screen.getByPlaceholderText('Give your recap a name')).toBeInTheDocument();
});
it('should display current recap name value', () => {
renderWithContext(
<RecapConfiguration
{...defaultProps}
recapName='My Test Recap'
/>,
);
const input = screen.getByPlaceholderText('Give your recap a name') as HTMLInputElement;
expect(input.value).toBe('My Test Recap');
});
it('should call setRecapName when name is changed', async () => {
const setRecapName = jest.fn();
renderWithContext(
<RecapConfiguration
{...defaultProps}
setRecapName={setRecapName}
/>,
);
const input = screen.getByPlaceholderText('Give your recap a name');
await userEvent.type(input, 'New Recap');
expect(setRecapName).toHaveBeenCalled();
});
it('should enforce maxLength of 100 characters', () => {
renderWithContext(<RecapConfiguration {...defaultProps}/>);
const input = screen.getByPlaceholderText('Give your recap a name') as HTMLInputElement;
expect(input.maxLength).toBe(100);
});
});
describe('Recap Type Selection', () => {
it('should render both recap type options', () => {
renderWithContext(<RecapConfiguration {...defaultProps}/>);
expect(screen.getByText('Recap selected channels')).toBeInTheDocument();
expect(screen.getByText('Recap all my unreads')).toBeInTheDocument();
});
it('should call setRecapType when selected channels option is clicked', async () => {
const setRecapType = jest.fn();
renderWithContext(
<RecapConfiguration
{...defaultProps}
setRecapType={setRecapType}
/>,
);
const selectedChannelsButton = screen.getByText('Recap selected channels').closest('button');
await userEvent.click(selectedChannelsButton!);
expect(setRecapType).toHaveBeenCalledWith('selected');
});
it('should call setRecapType when all unreads option is clicked', async () => {
const setRecapType = jest.fn();
renderWithContext(
<RecapConfiguration
{...defaultProps}
setRecapType={setRecapType}
/>,
);
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
await userEvent.click(allUnreadsButton!);
expect(setRecapType).toHaveBeenCalledWith('all_unreads');
});
it('should show selected state for selected channels option', () => {
renderWithContext(
<RecapConfiguration
{...defaultProps}
recapType='selected'
/>,
);
const selectedButton = screen.getByText('Recap selected channels').closest('button');
expect(selectedButton).toHaveClass('selected');
});
it('should show selected state for all unreads option', () => {
renderWithContext(
<RecapConfiguration
{...defaultProps}
recapType='all_unreads'
/>,
);
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
expect(allUnreadsButton).toHaveClass('selected');
});
it('should show check icon when selected channels is selected', () => {
renderWithContext(
<RecapConfiguration
{...defaultProps}
recapType='selected'
/>,
);
const selectedButton = screen.getByText('Recap selected channels').closest('button');
const checkIcon = selectedButton?.querySelector('.selected-icon');
expect(checkIcon).toBeInTheDocument();
});
it('should show check icon when all unreads is selected', () => {
renderWithContext(
<RecapConfiguration
{...defaultProps}
recapType='all_unreads'
/>,
);
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
const checkIcon = allUnreadsButton?.querySelector('.selected-icon');
expect(checkIcon).toBeInTheDocument();
});
});
describe('Unread Channels Handling', () => {
it('should disable all unreads option when no unread channels', () => {
renderWithContext(
<RecapConfiguration
{...defaultProps}
unreadChannels={[]}
/>,
);
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
expect(allUnreadsButton).toBeDisabled();
expect(allUnreadsButton).toHaveClass('disabled');
});
it('should enable all unreads option when unread channels exist', () => {
renderWithContext(<RecapConfiguration {...defaultProps}/>);
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
expect(allUnreadsButton).not.toBeDisabled();
expect(allUnreadsButton).not.toHaveClass('disabled');
});
it('should not call setRecapType when all unreads is clicked with no unread channels', async () => {
const setRecapType = jest.fn();
renderWithContext(
<RecapConfiguration
{...defaultProps}
setRecapType={setRecapType}
unreadChannels={[]}
/>,
);
const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button');
await userEvent.click(allUnreadsButton!);
expect(setRecapType).not.toHaveBeenCalled();
});
it('should show tooltip when all unreads option is disabled', () => {
renderWithContext(
<RecapConfiguration
{...defaultProps}
unreadChannels={[]}
/>,
);
// The WithTooltip component wraps the button when there are no unreads
expect(screen.getByText('Recap all my unreads')).toBeInTheDocument();
});
});
describe('Form Labels', () => {
it('should display name label', () => {
renderWithContext(<RecapConfiguration {...defaultProps}/>);
expect(screen.getByText('Give your recap a name')).toBeInTheDocument();
});
it('should display type selection label', () => {
renderWithContext(<RecapConfiguration {...defaultProps}/>);
expect(screen.getByText('What type of recap would you like?')).toBeInTheDocument();
});
});
describe('Type Descriptions', () => {
it('should show description for selected channels option', () => {
renderWithContext(<RecapConfiguration {...defaultProps}/>);
expect(screen.getByText('Choose the channels you would like included in your recap')).toBeInTheDocument();
});
it('should show description for all unreads option', () => {
renderWithContext(<RecapConfiguration {...defaultProps}/>);
expect(screen.getByText('Copilot will create a recap of all unreads across your channels.')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl, FormattedMessage} from 'react-intl';
import {ProductChannelsIcon, LightningBoltOutlineIcon, CheckCircleIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import WithTooltip from 'components/with_tooltip';
const RECAP_NAME_MAX_LENGTH = 100;
type Props = {
recapName: string;
setRecapName: (name: string) => void;
recapType: 'selected' | 'all_unreads' | null;
setRecapType: (type: 'selected' | 'all_unreads') => void;
unreadChannels: Channel[];
};
const RecapConfiguration = ({recapName, setRecapName, recapType, setRecapType, unreadChannels}: Props) => {
const {formatMessage} = useIntl();
const hasUnreadChannels = unreadChannels.length > 0;
const allUnreadsButton = (
<button
type='button'
className={`recap-type-card ${recapType === 'all_unreads' ? 'selected' : ''} ${hasUnreadChannels ? '' : 'disabled'}`}
onClick={() => hasUnreadChannels && setRecapType('all_unreads')}
disabled={!hasUnreadChannels}
>
<div className='recap-type-card-icon'>
<LightningBoltOutlineIcon size={24}/>
</div>
<div className='recap-type-card-content'>
<div className='recap-type-card-title'>
<FormattedMessage
id='recaps.modal.allUnreads'
defaultMessage='Recap all my unreads'
/>
</div>
<div className='recap-type-card-description'>
<FormattedMessage
id='recaps.modal.allUnreadsDesc'
defaultMessage='Copilot will create a recap of all unreads across your channels.'
/>
</div>
</div>
{recapType === 'all_unreads' && <CheckCircleIcon className='selected-icon'/>}
</button>
);
return (
<div className='step-one'>
<div className='form-group name-input-group'>
<label
className='form-label'
htmlFor='recap-name-input'
>
<FormattedMessage
id='recaps.modal.nameLabel'
defaultMessage='Give your recap a name'
/>
</label>
<div className='input-container'>
<input
id='recap-name-input'
type='text'
className='form-control'
placeholder={formatMessage({id: 'recaps.modal.namePlaceholder', defaultMessage: 'Give your recap a name'})}
value={recapName}
onChange={(e) => setRecapName(e.target.value)}
maxLength={RECAP_NAME_MAX_LENGTH}
/>
</div>
</div>
<div className='form-group type-selection-group'>
<div
className='form-label'
id='recap-type-label'
>
<FormattedMessage
id='recaps.modal.typeLabel'
defaultMessage='What type of recap would you like?'
/>
</div>
<div className='recap-type-options'>
<button
type='button'
className={`recap-type-card ${recapType === 'selected' ? 'selected' : ''}`}
onClick={() => setRecapType('selected')}
>
<div className='recap-type-card-icon'>
<ProductChannelsIcon size={24}/>
</div>
<div className='recap-type-card-content'>
<div className='recap-type-card-title'>
<FormattedMessage
id='recaps.modal.selectedChannels'
defaultMessage='Recap selected channels'
/>
</div>
<div className='recap-type-card-description'>
<FormattedMessage
id='recaps.modal.selectedChannelsDesc'
defaultMessage='Choose the channels you would like included in your recap'
/>
</div>
</div>
{recapType === 'selected' && <CheckCircleIcon className='selected-icon'/>}
</button>
{hasUnreadChannels ? allUnreadsButton : (
<WithTooltip
title={formatMessage({id: 'recaps.modal.noUnreadsAvailable', defaultMessage: 'No unread channels available'})}
hint={formatMessage({id: 'recaps.modal.noUnreadsAvailableHint', defaultMessage: 'You currently have no unread messages in any channels'})}
>
{allUnreadsButton}
</WithTooltip>
)}
</div>
</div>
</div>
);
};
export default RecapConfiguration;

View file

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

View file

@ -0,0 +1,323 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {RecapChannel} from '@mattermost/types/recaps';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import RecapChannelCard from './recap_channel_card';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
jest.mock('mattermost-redux/actions/channels', () => ({
readMultipleChannels: jest.fn((channelIds) => ({type: 'READ_MULTIPLE_CHANNELS', channelIds})),
}));
jest.mock('actions/views/channel', () => ({
switchToChannel: jest.fn((channel) => ({type: 'SWITCH_TO_CHANNEL', channel})),
}));
jest.mock('components/external_link', () => {
return function ExternalLink({children, href}: {children: React.ReactNode; href: string}) {
return <a href={href}>{children}</a>;
};
});
jest.mock('./recap_menu', () => {
return function RecapMenu({actions}: {actions: any[]}) {
return (
<div data-testid='recap-menu'>
{actions.map((action) => (
<button
key={action.id}
onClick={action.onClick}
data-testid={`menu-action-${action.id}`}
>
{action.label}
</button>
))}
</div>
);
};
});
jest.mock('./recap_text_formatter', () => {
return function RecapTextFormatter({text}: {text: string}) {
return <div data-testid='recap-text'>{text}</div>;
};
});
describe('RecapChannelCard', () => {
const mockChannel = TestHelper.getChannelMock({
id: 'channel1',
name: 'test-channel',
display_name: 'Test Channel',
});
const baseState = {
entities: {
channels: {
channels: {
channel1: mockChannel,
},
},
teams: {
currentTeamId: 'team1',
teams: {
team1: TestHelper.getTeamMock({
id: 'team1',
name: 'test-team',
}),
},
},
},
};
const mockRecapChannel: RecapChannel = {
id: 'recap_channel1',
recap_id: 'recap1',
channel_id: 'channel1',
channel_name: 'test-channel',
highlights: ['Important update from @john', 'New feature released'],
action_items: ['Review PR #123', 'Schedule meeting'],
source_post_ids: ['post1', 'post2'],
create_at: 1000,
};
beforeEach(() => {
jest.clearAllMocks();
});
test('should render channel name', () => {
renderWithContext(
<RecapChannelCard channel={mockRecapChannel}/>,
baseState,
);
expect(screen.getByText('test-channel')).toBeInTheDocument();
});
test('should render highlights section', () => {
renderWithContext(
<RecapChannelCard channel={mockRecapChannel}/>,
baseState,
);
expect(screen.getByText('Highlights')).toBeInTheDocument();
expect(screen.getByText('Important update from @john')).toBeInTheDocument();
expect(screen.getByText('New feature released')).toBeInTheDocument();
});
test('should render action items section', () => {
renderWithContext(
<RecapChannelCard channel={mockRecapChannel}/>,
baseState,
);
expect(screen.getByText('Action items:')).toBeInTheDocument();
expect(screen.getByText('Review PR #123')).toBeInTheDocument();
expect(screen.getByText('Schedule meeting')).toBeInTheDocument();
});
test('should not render when no highlights or action items', () => {
const emptyChannel: RecapChannel = {
...mockRecapChannel,
highlights: [],
action_items: [],
};
const {container} = renderWithContext(
<RecapChannelCard channel={emptyChannel}/>,
baseState,
);
expect(container.firstChild).toBeNull();
});
test('should toggle collapse state when collapse button clicked', async () => {
const user = userEvent.setup();
renderWithContext(
<RecapChannelCard channel={mockRecapChannel}/>,
baseState,
);
// Initially expanded, content should be visible
expect(screen.getByText('Highlights')).toBeInTheDocument();
// Find and click the collapse button
const collapseButton = screen.getByRole('button', {name: ''});
await user.click(collapseButton);
// Content should be hidden after collapse
expect(screen.queryByText('Highlights')).not.toBeInTheDocument();
});
test('should dispatch switchToChannel when channel name clicked', async () => {
const {switchToChannel} = require('actions/views/channel');
const user = userEvent.setup();
renderWithContext(
<RecapChannelCard channel={mockRecapChannel}/>,
baseState,
);
const channelButton = screen.getByText('test-channel');
await user.click(channelButton);
expect(mockDispatch).toHaveBeenCalled();
expect(switchToChannel).toHaveBeenCalledWith(mockChannel);
});
test('should render menu with actions', () => {
renderWithContext(
<RecapChannelCard channel={mockRecapChannel}/>,
baseState,
);
expect(screen.getByTestId('recap-menu')).toBeInTheDocument();
});
test('should call mark channel as read action', async () => {
const {readMultipleChannels} = require('mattermost-redux/actions/channels');
const user = userEvent.setup();
renderWithContext(
<RecapChannelCard channel={mockRecapChannel}/>,
baseState,
);
const markReadButton = screen.getByTestId('menu-action-mark-channel-read');
await user.click(markReadButton);
expect(mockDispatch).toHaveBeenCalled();
expect(readMultipleChannels).toHaveBeenCalledWith(['channel1']);
});
test('should call open channel action', async () => {
const {switchToChannel} = require('actions/views/channel');
const user = userEvent.setup();
renderWithContext(
<RecapChannelCard channel={mockRecapChannel}/>,
baseState,
);
const openChannelButton = screen.getByTestId('menu-action-open-channel');
await user.click(openChannelButton);
expect(mockDispatch).toHaveBeenCalled();
expect(switchToChannel).toHaveBeenCalledWith(mockChannel);
});
test('should parse permalinks from highlights', () => {
const channelWithPermalinks: RecapChannel = {
...mockRecapChannel,
highlights: ['Update from @john [PERMALINK:https://example.com/post1]'],
};
const {container} = renderWithContext(
<RecapChannelCard channel={channelWithPermalinks}/>,
baseState,
);
// Check that the text is rendered without the permalink tag
expect(screen.getByText('Update from @john')).toBeInTheDocument();
// Check that a link is rendered
const link = container.querySelector('a[href="https://example.com/post1"]');
expect(link).toBeInTheDocument();
});
test('should parse permalinks from action items', () => {
const channelWithPermalinks: RecapChannel = {
...mockRecapChannel,
action_items: ['Review PR [PERMALINK:https://example.com/pr123]'],
};
const {container} = renderWithContext(
<RecapChannelCard channel={channelWithPermalinks}/>,
baseState,
);
// Check that the text is rendered without the permalink tag
expect(screen.getByText('Review PR')).toBeInTheDocument();
// Check that a link is rendered
const link = container.querySelector('a[href="https://example.com/pr123"]');
expect(link).toBeInTheDocument();
});
test('should render badges for items without permalinks', () => {
const {container} = renderWithContext(
<RecapChannelCard channel={mockRecapChannel}/>,
baseState,
);
// Check for badge elements
const badges = container.querySelectorAll('.recap-item-badge');
expect(badges.length).toBeGreaterThan(0);
});
test('should disable channel button when channel object not found', () => {
const stateWithoutChannel = {
entities: {
channels: {
channels: {},
},
teams: {
currentTeamId: 'team1',
teams: {
team1: TestHelper.getTeamMock({
id: 'team1',
name: 'test-team',
}),
},
},
},
};
renderWithContext(
<RecapChannelCard channel={mockRecapChannel}/>,
stateWithoutChannel,
);
const channelButton = screen.getByText('test-channel');
expect(channelButton).toBeDisabled();
});
test('should render only highlights when action items are empty', () => {
const channelWithOnlyHighlights: RecapChannel = {
...mockRecapChannel,
action_items: [],
};
renderWithContext(
<RecapChannelCard channel={channelWithOnlyHighlights}/>,
baseState,
);
expect(screen.getByText('Highlights')).toBeInTheDocument();
expect(screen.queryByText('Action items:')).not.toBeInTheDocument();
});
test('should render only action items when highlights are empty', () => {
const channelWithOnlyActions: RecapChannel = {
...mockRecapChannel,
highlights: [],
};
renderWithContext(
<RecapChannelCard channel={channelWithOnlyActions}/>,
baseState,
);
expect(screen.queryByText('Highlights')).not.toBeInTheDocument();
expect(screen.getByText('Action items:')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,224 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useCallback, useMemo} from 'react';
import {useIntl, FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {CheckAllIcon, ArrowExpandIcon, ChevronDownIcon, ChevronUpIcon} from '@mattermost/compass-icons/components';
import type {RecapChannel} from '@mattermost/types/recaps';
import {readMultipleChannels} from 'mattermost-redux/actions/channels';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {switchToChannel} from 'actions/views/channel';
import ExternalLink from 'components/external_link';
import type {GlobalState} from 'types/store';
import RecapMenu from './recap_menu';
import type {RecapMenuAction} from './recap_menu';
import RecapTextFormatter from './recap_text_formatter';
type Props = {
channel: RecapChannel;
};
type ParsedItem = {
text: string;
permalink: string | null;
};
// Helper function to parse permalink from text
const parsePermalink = (text: string): ParsedItem => {
// Match pattern: [PERMALINK:url]
const permalinkRegex = /\[PERMALINK:([^\]]+)\]/;
const match = text.match(permalinkRegex);
if (match) {
return {
text: text.replace(permalinkRegex, '').trim(),
permalink: match[1],
};
}
return {
text,
permalink: null,
};
};
const RecapChannelCard = ({channel}: Props) => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const [isCollapsed, setIsCollapsed] = useState(false);
const channelObject = useSelector((state: GlobalState) => getChannel(state, channel.channel_id));
const hasHighlights = channel.highlights && channel.highlights.length > 0;
const hasActionItems = channel.action_items && channel.action_items.length > 0;
const handleChannelClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (channelObject) {
dispatch(switchToChannel(channelObject));
}
}, [dispatch, channelObject]);
const handleMarkChannelRead = useCallback(() => {
dispatch(readMultipleChannels([channel.channel_id]));
}, [dispatch, channel.channel_id]);
const handleOpenChannel = useCallback(() => {
if (channelObject) {
dispatch(switchToChannel(channelObject));
}
}, [dispatch, channelObject]);
const menuActions: RecapMenuAction[] = useMemo(() => [
{
id: 'mark-channel-read',
icon: <CheckAllIcon size={18}/>,
label: formatMessage({
id: 'recaps.menu.markChannelRead',
defaultMessage: 'Mark this channel as read',
}),
onClick: handleMarkChannelRead,
},
{
id: 'open-channel',
icon: <ArrowExpandIcon size={18}/>,
label: formatMessage({
id: 'recaps.menu.openChannel',
defaultMessage: 'Open channel',
}),
onClick: handleOpenChannel,
},
], [formatMessage, handleMarkChannelRead, handleOpenChannel]);
if (!hasHighlights && !hasActionItems) {
return null;
}
return (
<div className='recap-channel-card'>
<div className='recap-channel-header'>
<button
className='recap-channel-name-tag'
onClick={handleChannelClick}
disabled={!channelObject}
>
{channel.channel_name}
</button>
<div className='recap-channel-header-actions'>
<button
className='recap-channel-collapse-button'
onClick={() => setIsCollapsed(!isCollapsed)}
>
{isCollapsed ? <ChevronDownIcon size={16}/> : <ChevronUpIcon size={16}/>}
</button>
<RecapMenu
actions={menuActions}
ariaLabel={formatMessage(
{
id: 'recaps.channelMenu.ariaLabel',
defaultMessage: 'Options for {channelName}',
},
{channelName: channel.channel_name},
)}
/>
</div>
</div>
{!isCollapsed && (
<div className='recap-channel-content'>
{hasHighlights && (
<div className='recap-section'>
<h4 className='recap-section-title'>
<FormattedMessage
id='recaps.highlights'
defaultMessage='Highlights'
/>
</h4>
<ul className='recap-list'>
{channel.highlights.map((highlight, index) => {
const {text, permalink} = parsePermalink(highlight);
return (
<li
key={index}
className='recap-list-item'
>
<RecapTextFormatter
text={text}
className='recap-item-text'
/>
{permalink ? (
<ExternalLink
href={permalink}
className='recap-item-badge recap-item-badge-link'
location='recap_highlight_badge'
onClick={(e) => e.stopPropagation()}
>
{index + 1}
</ExternalLink>
) : (
<span className='recap-item-badge'>{index + 1}</span>
)}
</li>
);
})}
</ul>
</div>
)}
{hasActionItems && (
<div className='recap-section'>
<h4 className='recap-section-title'>
<FormattedMessage
id='recaps.actionItems'
defaultMessage='Action items:'
/>
</h4>
<ul className='recap-list'>
{channel.action_items.map((actionItem, index) => {
const {text, permalink} = parsePermalink(actionItem);
const badgeNumber = (hasHighlights ? channel.highlights.length : 0) + index + 1;
return (
<li
key={index}
className='recap-list-item'
>
<RecapTextFormatter
text={text}
className='recap-item-text'
/>
{permalink ? (
<ExternalLink
href={permalink}
className='recap-item-badge recap-item-badge-link'
location='recap_action_item_badge'
onClick={(e) => e.stopPropagation()}
>
{badgeNumber}
</ExternalLink>
) : (
<span className='recap-item-badge'>
{badgeNumber}
</span>
)}
</li>
);
})}
</ul>
</div>
)}
</div>
)}
</div>
);
};
export default RecapChannelCard;

View file

@ -0,0 +1,224 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useMemo, useCallback} from 'react';
import {useIntl, FormattedDate, FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {CheckAllIcon, RefreshIcon, TrashCanOutlineIcon, CheckCircleIcon} from '@mattermost/compass-icons/components';
import type {Recap} from '@mattermost/types/recaps';
import {RecapStatus} from '@mattermost/types/recaps';
import {readMultipleChannels} from 'mattermost-redux/actions/channels';
import {markRecapAsRead, deleteRecap, regenerateRecap} from 'mattermost-redux/actions/recaps';
import {getAgents} from 'mattermost-redux/selectors/entities/agents';
import useGetAgentsBridgeEnabled from 'components/common/hooks/useGetAgentsBridgeEnabled';
import ConfirmModal from 'components/confirm_modal';
import RecapChannelCard from './recap_channel_card';
import RecapMenu from './recap_menu';
import type {RecapMenuAction} from './recap_menu';
import RecapProcessing from './recap_processing';
type Props = {
recap: Recap;
isExpanded: boolean;
onToggle: () => void;
};
const RecapItem = ({recap, isExpanded, onToggle}: Props) => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const agents = useSelector(getAgents);
const agentsBridgeEnabled = useGetAgentsBridgeEnabled();
const isProcessing = recap.status === RecapStatus.PENDING || recap.status === RecapStatus.PROCESSING;
const isFailed = recap.status === RecapStatus.FAILED;
// Find the agent that generated this recap
const generatingAgent = agents.find((agent) => agent.id === recap.bot_id);
const agentDisplayName = generatingAgent?.displayName || formatMessage({id: 'recaps.defaultAgent', defaultMessage: 'Copilot'});
const handleMarkAllChannelsRead = useCallback(() => {
if (recap.channels && recap.channels.length > 0) {
const channelIds = recap.channels.map((channel) => channel.channel_id);
dispatch(readMultipleChannels(channelIds));
}
}, [dispatch, recap.channels]);
const handleRegenerateRecap = useCallback(() => {
dispatch(regenerateRecap(recap.id));
}, [dispatch, recap.id]);
const menuActions: RecapMenuAction[] = useMemo(() => {
const actions: RecapMenuAction[] = [];
// Only show "Mark all channels as read" for successful recaps
if (!isFailed) {
actions.push({
id: 'mark-all-channels-read',
icon: <CheckAllIcon size={18}/>,
label: formatMessage({
id: 'recaps.menu.markAllChannelsRead',
defaultMessage: 'Mark all channels as read',
}),
onClick: handleMarkAllChannelsRead,
});
}
actions.push({
id: 'regenerate-recap',
icon: <RefreshIcon size={18}/>,
label: formatMessage({
id: 'recaps.menu.regenerateRecap',
defaultMessage: 'Regenerate this recap',
}),
onClick: handleRegenerateRecap,
disabled: !agentsBridgeEnabled,
});
return actions;
}, [formatMessage, handleMarkAllChannelsRead, handleRegenerateRecap, isFailed, agentsBridgeEnabled]);
const handleDelete = () => {
dispatch(deleteRecap(recap.id));
setShowDeleteConfirm(false);
};
if (isProcessing) {
return <RecapProcessing recap={recap}/>;
}
// Determine class names based on state
let itemClassName = 'recap-item';
if (isFailed) {
itemClassName += ' recap-item-failed collapsed';
} else {
itemClassName += isExpanded ? ' expanded' : ' collapsed';
}
// Only make header clickable for successful recaps
const headerProps = isFailed ? {} : {onClick: onToggle};
return (
<div className={itemClassName}>
<div
className='recap-item-header'
{...headerProps}
>
<div className='recap-item-title-section'>
<h3 className='recap-item-title'>{recap.title}</h3>
<div className='recap-item-metadata'>
<span className='metadata-item'>
<FormattedDate
value={new Date(recap.create_at)}
month='long'
day='numeric'
year='numeric'
/>
</span>
{isFailed ? (
<>
<span className='metadata-separator'>{'•'}</span>
<span className='metadata-item error-text'>
{formatMessage({id: 'recaps.status.failed', defaultMessage: 'Failed'})}
</span>
</>
) : (
<>
{recap.total_message_count > 0 && (
<>
<span className='metadata-separator'>{'•'}</span>
<span className='metadata-item'>
{formatMessage(
{id: 'recaps.messageCount', defaultMessage: 'Recapped {count} {count, plural, one {message} other {messages}}'},
{count: recap.total_message_count},
)}
</span>
</>
)}
<span className='metadata-separator'>{'•'}</span>
<span className='metadata-item'>
{formatMessage(
{id: 'recaps.generatedBy', defaultMessage: 'Generated by {agentName}'},
{agentName: agentDisplayName},
)}
</span>
</>
)}
</div>
</div>
<div
className='recap-item-actions'
onClick={(e) => e.stopPropagation()}
>
{!isFailed && recap.read_at === 0 && (
<button
className='recap-action-button'
onClick={() => dispatch(markRecapAsRead(recap.id))}
>
<CheckCircleIcon size={12}/>
{formatMessage({id: 'recaps.markRead', defaultMessage: 'Mark read'})}
</button>
)}
<button
className='recap-icon-button recap-delete-button'
onClick={() => setShowDeleteConfirm(true)}
>
<TrashCanOutlineIcon size={16}/>
</button>
{recap.read_at === 0 && (
<RecapMenu
actions={menuActions}
ariaLabel={formatMessage(
{
id: 'recaps.menu.ariaLabel',
defaultMessage: 'Options for {title}',
},
{title: recap.title},
)}
/>
)}
</div>
</div>
{!isFailed && isExpanded && recap.channels && recap.channels.length > 0 && (
<div className='recap-item-content'>
<div className='recap-channels-list'>
{recap.channels.map((channel) => (
<RecapChannelCard
key={channel.id}
channel={channel}
/>
))}
</div>
</div>
)}
<ConfirmModal
show={showDeleteConfirm}
title={formatMessage({id: 'recaps.delete.confirm.title', defaultMessage: 'Delete recap?'})}
message={
<FormattedMessage
id='recaps.delete.confirm.message'
defaultMessage='Are you sure you want to delete <strong>{title}</strong>? This action cannot be undone.'
values={{
title: recap.title,
strong: (chunks: React.ReactNode) => <strong>{chunks}</strong>,
}}
/>
}
confirmButtonText={formatMessage({id: 'recaps.delete.confirm.button', defaultMessage: 'Delete'})}
confirmButtonClass='btn btn-danger'
onConfirm={handleDelete}
onCancel={() => setShowDeleteConfirm(false)}
onExited={() => setShowDeleteConfirm(false)}
/>
</div>
);
};
export default RecapItem;

View file

@ -0,0 +1,168 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {CheckAllIcon, RefreshIcon} from '@mattermost/compass-icons/components';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import RecapMenu from './recap_menu';
import type {RecapMenuAction} from './recap_menu';
jest.mock('components/menu', () => ({
Container: ({children, menuButton}: any) => {
const {class: className, ...buttonProps} = menuButton;
return (
<div data-testid='menu-container'>
<button
{...buttonProps}
className={className}
data-testid='menu-button'
>
{menuButton.children}
</button>
{children}
</div>
);
},
Item: ({leadingElement, labels, onClick, disabled}: any) => (
<button
data-testid='menu-item'
onClick={onClick}
disabled={disabled}
>
{leadingElement}
{labels}
</button>
),
}));
describe('RecapMenu', () => {
const mockOnClick1 = jest.fn();
const mockOnClick2 = jest.fn();
const mockActions: RecapMenuAction[] = [
{
id: 'action1',
icon: <CheckAllIcon size={18}/>,
label: 'Mark as read',
onClick: mockOnClick1,
},
{
id: 'action2',
icon: <RefreshIcon size={18}/>,
label: 'Regenerate',
onClick: mockOnClick2,
},
];
beforeEach(() => {
jest.clearAllMocks();
});
test('should render menu button', () => {
renderWithContext(<RecapMenu actions={mockActions}/>);
expect(screen.getByTestId('menu-container')).toBeInTheDocument();
});
test('should render all menu items', () => {
renderWithContext(<RecapMenu actions={mockActions}/>);
const menuItems = screen.getAllByTestId('menu-item');
expect(menuItems).toHaveLength(2);
});
test('should render menu item labels', () => {
renderWithContext(<RecapMenu actions={mockActions}/>);
expect(screen.getByText('Mark as read')).toBeInTheDocument();
expect(screen.getByText('Regenerate')).toBeInTheDocument();
});
test('should call onClick when menu item is clicked', async () => {
const user = userEvent.setup();
renderWithContext(<RecapMenu actions={mockActions}/>);
const menuItems = screen.getAllByTestId('menu-item');
await user.click(menuItems[0]);
expect(mockOnClick1).toHaveBeenCalledTimes(1);
expect(mockOnClick2).not.toHaveBeenCalled();
});
test('should handle disabled menu items', () => {
const disabledActions: RecapMenuAction[] = [
{
id: 'action1',
icon: <CheckAllIcon size={18}/>,
label: 'Disabled action',
onClick: mockOnClick1,
disabled: true,
},
];
renderWithContext(<RecapMenu actions={disabledActions}/>);
const menuItem = screen.getByTestId('menu-item');
expect(menuItem).toBeDisabled();
});
test('should render with custom button className', () => {
const customClassName = 'custom-menu-button';
renderWithContext(
<RecapMenu
actions={mockActions}
buttonClassName={customClassName}
/>,
);
const button = screen.getByTestId('menu-button');
expect(button).toHaveClass(customClassName);
});
test('should use custom aria label when provided', () => {
const customAriaLabel = 'Custom options menu';
renderWithContext(
<RecapMenu
actions={mockActions}
ariaLabel={customAriaLabel}
/>,
);
const button = screen.getByTestId('menu-button');
expect(button).toHaveAttribute('aria-label', customAriaLabel);
});
test('should use default aria label when not provided', () => {
renderWithContext(<RecapMenu actions={mockActions}/>);
const button = screen.getByTestId('menu-button');
expect(button).toHaveAttribute('aria-label', 'Recap options');
});
test('should handle empty actions array', () => {
renderWithContext(<RecapMenu actions={[]}/>);
const menuItems = screen.queryAllByTestId('menu-item');
expect(menuItems).toHaveLength(0);
});
test('should handle destructive menu items', () => {
const destructiveAction: RecapMenuAction[] = [
{
id: 'delete',
icon: <RefreshIcon size={18}/>,
label: 'Delete',
onClick: mockOnClick1,
isDestructive: true,
},
];
renderWithContext(<RecapMenu actions={destructiveAction}/>);
expect(screen.getByText('Delete')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {ReactNode} from 'react';
import {DotsHorizontalIcon} from '@mattermost/compass-icons/components';
import * as Menu from 'components/menu';
export type RecapMenuAction = {
id: string;
icon: ReactNode;
label: ReactNode;
onClick: () => void;
isDestructive?: boolean;
disabled?: boolean;
};
interface RecapMenuProps {
actions: RecapMenuAction[];
buttonClassName?: string;
ariaLabel?: string;
}
export const RecapMenu: React.FC<RecapMenuProps> = ({
actions,
buttonClassName = 'recap-icon-button',
ariaLabel = 'Recap options',
}) => {
const menuId = `recap-menu-${Math.random().toString(36).substr(2, 9)}`;
const buttonId = `${menuId}-button`;
return (
<Menu.Container
menuButton={{
id: buttonId,
class: buttonClassName,
'aria-label': ariaLabel,
children: <DotsHorizontalIcon size={16}/>,
}}
menu={{
id: menuId,
'aria-label': ariaLabel,
}}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
{actions.map((action) => (
<Menu.Item
key={action.id}
leadingElement={action.icon}
labels={<span>{action.label}</span>}
onClick={action.onClick}
isDestructive={action.isDestructive}
disabled={action.disabled}
/>
))}
</Menu.Container>
);
};
export default RecapMenu;

View file

@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Recap} from '@mattermost/types/recaps';
import {RecapStatus} from '@mattermost/types/recaps';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import RecapProcessing from './recap_processing';
describe('RecapProcessing', () => {
const mockRecap: Recap = {
id: 'recap1',
title: 'Daily Standup Recap',
user_id: 'user1',
bot_id: 'bot1',
status: RecapStatus.PROCESSING,
create_at: 1000,
update_at: 1000,
delete_at: 0,
read_at: 0,
channels: [],
total_message_count: 0,
};
test('should render recap title', () => {
renderWithContext(<RecapProcessing recap={mockRecap}/>);
expect(screen.getByText('Daily Standup Recap')).toBeInTheDocument();
});
test('should render processing subtitle message', () => {
renderWithContext(<RecapProcessing recap={mockRecap}/>);
expect(screen.getByText("Recap created. You'll receive a summary shortly")).toBeInTheDocument();
});
test('should render processing message', () => {
renderWithContext(<RecapProcessing recap={mockRecap}/>);
expect(screen.getByText("We're working on your recap. Check back shortly")).toBeInTheDocument();
});
test('should render spinner', () => {
const {container} = renderWithContext(<RecapProcessing recap={mockRecap}/>);
expect(container.querySelector('.spinner-large')).toBeInTheDocument();
});
test('should have correct CSS classes', () => {
const {container} = renderWithContext(<RecapProcessing recap={mockRecap}/>);
expect(container.querySelector('.recap-processing')).toBeInTheDocument();
expect(container.querySelector('.recap-processing-header')).toBeInTheDocument();
expect(container.querySelector('.recap-processing-content')).toBeInTheDocument();
expect(container.querySelector('.recap-processing-spinner')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {Recap} from '@mattermost/types/recaps';
type Props = {
recap: Recap;
};
const RecapProcessing = ({recap}: Props) => {
return (
<div className='recap-processing'>
<div className='recap-processing-header'>
<h2 className='recap-processing-title'>{recap.title}</h2>
<div className='recap-processing-subtitle'>
<FormattedMessage
id='recaps.processing.subtitle'
defaultMessage="Recap created. You'll receive a summary shortly"
/>
</div>
</div>
<div className='recap-processing-content'>
<div className='recap-processing-spinner'>
<div className='spinner-large'/>
</div>
<p className='recap-processing-message'>
<FormattedMessage
id='recaps.processing.message'
defaultMessage="We're working on your recap. Check back shortly"
/>
</p>
</div>
</div>
);
};
export default RecapProcessing;

View file

@ -0,0 +1,102 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import RecapTextFormatter from './recap_text_formatter';
jest.mock('components/markdown', () => {
return function Markdown({message}: {message: string}) {
return <div data-testid='markdown'>{message}</div>;
};
});
describe('RecapTextFormatter', () => {
const baseState = {
entities: {
channels: {
channels: {},
channelsInTeam: {},
myMembers: {},
},
teams: {
currentTeamId: 'team1',
teams: {
team1: TestHelper.getTeamMock({
id: 'team1',
name: 'test-team',
display_name: 'Test Team',
}),
},
},
},
};
test('should render text content', () => {
const text = 'This is a recap message';
renderWithContext(
<RecapTextFormatter text={text}/>,
baseState,
);
expect(screen.getByTestId('markdown')).toHaveTextContent(text);
});
test('should strip HTML tags from text', () => {
const text = 'This is <strong>bold</strong> text';
renderWithContext(
<RecapTextFormatter text={text}/>,
baseState,
);
expect(screen.getByTestId('markdown')).toHaveTextContent('This is bold text');
});
test('should apply custom className when provided', () => {
const text = 'Test message';
const className = 'custom-class';
const {container} = renderWithContext(
<RecapTextFormatter
text={text}
className={className}
/>,
baseState,
);
expect(container.querySelector(`.${className}`)).toBeInTheDocument();
});
test('should handle text with multiple HTML tags', () => {
const text = '<div><p>Nested <span>HTML</span> tags</p></div>';
renderWithContext(
<RecapTextFormatter text={text}/>,
baseState,
);
expect(screen.getByTestId('markdown')).toHaveTextContent('Nested HTML tags');
});
test('should handle empty text', () => {
renderWithContext(
<RecapTextFormatter text=''/>,
baseState,
);
expect(screen.getByTestId('markdown')).toBeInTheDocument();
});
test('should render with default props when className not provided', () => {
const text = 'Default styling test';
const {container} = renderWithContext(
<RecapTextFormatter text={text}/>,
baseState,
);
// Should render a div wrapper
expect(container.querySelector('div')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useSelector} from 'react-redux';
import {getChannelsNameMapInCurrentTeam} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentRelativeTeamUrl, getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import Markdown from 'components/markdown';
import {handleFormattedTextClick} from 'utils/utils';
type Props = {
text: string;
className?: string;
};
const RecapTextFormatter = ({text, className}: Props) => {
const channelNamesMap = useSelector(getChannelsNameMapInCurrentTeam);
const currentTeam = useSelector(getCurrentTeam);
const currentRelativeTeamUrl = useSelector(getCurrentRelativeTeamUrl);
// Remove any existing HTML tags from the text for safety
const cleanText = text.replace(/<[^>]*>/g, '');
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
handleFormattedTextClick(e, currentRelativeTeamUrl);
}, [currentRelativeTeamUrl]);
return (
<div
className={className}
onClick={handleClick}
>
{/* This component is leveraged so that @username's can be clicked, showing the user info popover */}
<Markdown
message={cleanText}
channelNamesMap={channelNamesMap}
options={{
atMentions: true,
markdown: false, // Disable markdown parsing since this is plain text
singleline: false,
mentionHighlight: false, // Don't highlight mentions in recaps
team: currentTeam,
}}
/>
</div>
);
};
export default RecapTextFormatter;

View file

@ -0,0 +1,536 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.recaps-container {
display: flex;
height: 100%;
flex-direction: column;
background-color: var(--center-channel-bg);
.recaps-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
background-color: var(--center-channel-bg);
.recaps-header-left {
display: flex;
align-items: center;
gap: 16px;
}
.recaps-title-container {
display: flex;
align-items: center;
padding: 4px;
gap: 8px;
.icon {
font-size: 16px;
}
.recaps-title {
margin: 0;
color: var(--center-channel-color);
font-family: 'Metropolis', sans-serif;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
}
.recaps-tabs {
display: flex;
align-items: center;
gap: 0;
.recaps-tab {
padding: 22px 6px;
border: none;
border-bottom: 2px solid transparent;
background: none;
color: rgba(var(--center-channel-color-rgb), 0.64);
cursor: pointer;
font-size: 12px;
font-weight: 600;
line-height: 12px;
transition: all 0.2s;
&:hover {
color: var(--center-channel-color);
}
&.active {
border-bottom-color: var(--button-bg);
color: var(--button-bg);
}
}
}
.recap-add-button {
display: flex;
align-items: center;
padding: 8px 16px;
border: none;
border-radius: var(--radius-s);
font-size: 12px;
font-weight: 600;
gap: 6px;
line-height: 16px;
.icon {
font-size: 12px;
}
&:hover {
background-color: rgba(var(--button-bg-rgb), 0.12);
}
}
}
.recaps-content {
flex: 1;
padding: 24px;
background-color: rgba(var(--center-channel-color-rgb), 0.04);
overflow-y: auto;
}
// Recaps List Styles
.recaps-list {
display: flex;
max-width: 966px;
flex-direction: column;
margin: 0 auto;
gap: 12px;
}
.recap-item {
overflow: hidden;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
border-radius: var(--radius-s);
background-color: var(--center-channel-bg);
transition: all 0.2s;
&.expanded {
.recap-item-header {
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
}
}
&.recap-item-failed {
.recap-item-header {
cursor: default;
&:hover {
background-color: transparent;
}
}
}
.recap-item-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.04);
}
.recap-item-title-section {
min-width: 0;
flex: 1;
.recap-item-title {
margin: 0 0 8px;
color: var(--center-channel-color);
font-family: 'Metropolis', sans-serif;
font-size: 22px;
font-weight: 600;
line-height: 28px;
}
.recap-item-metadata {
display: flex;
align-items: center;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
font-weight: 600;
gap: 4px;
line-height: 16px;
.metadata-separator {
margin: 0 4px;
}
.copilot-icon {
width: 12px;
height: 12px;
margin: 0 4px;
}
.error-text {
color: var(--error-text);
}
}
}
.recap-item-actions {
display: flex;
align-items: center;
margin-left: 16px;
gap: 8px;
.recap-action-button {
display: flex;
align-items: center;
padding: 8px 12px;
border: none;
border-radius: var(--radius-s);
background-color: transparent;
color: var(--button-bg);
cursor: pointer;
font-size: 12px;
font-weight: 600;
gap: 4px;
&:hover {
background-color: rgba(var(--button-bg-rgb), 0.08);
}
.icon {
font-size: 12px;
}
}
.recap-icon-button {
padding: 6px;
border: none;
border-radius: var(--radius-s);
background: none;
cursor: pointer;
.icon {
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 16px;
}
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
// Trash icon should be red
&.recap-delete-button {
color: var(--dnd-indicator);
}
}
}
}
.recap-item-content {
padding: 24px;
.recap-channels-list {
display: flex;
flex-direction: column;
gap: 12px;
}
}
}
.recap-all-caught-up {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
margin-top: 12px;
color: var(--center-channel-color);
font-family: 'Metropolis', sans-serif;
font-size: 20px;
font-weight: 600;
gap: 12px;
line-height: 28px;
.icon {
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 24px;
}
}
.recaps-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 24px;
text-align: center;
.empty-state-icon {
margin-bottom: 12px;
.icon {
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 48px;
}
}
.empty-state-title {
margin: 0 0 8px;
color: var(--center-channel-color);
font-family: 'Metropolis', sans-serif;
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.empty-state-description {
margin: 0;
color: rgba(var(--center-channel-color-rgb), 0.72);
font-size: 14px;
line-height: 20px;
}
}
// Recap Channel Card Styles
.recap-channel-card {
padding: 16px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
border-radius: var(--radius-s);
background-color: var(--center-channel-bg);
.recap-channel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.recap-channel-name-tag {
padding: 2px 6px;
border: none;
border-radius: var(--radius-s);
background-color: rgba(var(--center-channel-color-rgb), 0.08);
color: var(--center-channel-color);
cursor: pointer;
font-size: 12px;
font-weight: 600;
line-height: 16px;
transition: background-color 0.2s, color 0.2s;
&:hover:not(:disabled) {
background-color: rgba(var(--center-channel-color-rgb), 0.16);
color: var(--button-bg);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
.recap-channel-header-actions {
display: flex;
align-items: center;
gap: 4px;
.recap-channel-collapse-button {
padding: 6px;
border: none;
border-radius: var(--radius-s);
background: none;
cursor: pointer;
.icon {
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 16px;
}
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
}
.recap-icon-button {
padding: 6px;
border: none;
border-radius: var(--radius-s);
background: none;
cursor: pointer;
.icon {
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 16px;
}
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
}
}
}
.recap-channel-content {
display: flex;
flex-direction: column;
gap: 8px;
.recap-section {
display: flex;
flex-direction: column;
gap: 8px;
.recap-section-title {
margin: 0;
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
.recap-list {
display: flex;
flex-direction: column;
padding-left: 0;
margin: 0;
gap: 4px;
list-style: none;
.recap-list-item {
position: relative;
display: flex;
align-items: flex-start;
padding-left: 21px;
gap: 8px;
&::before {
position: absolute;
left: 0;
color: var(--center-channel-color);
content: '';
}
.recap-item-text {
flex: 1;
color: var(--center-channel-color);
font-size: 14px;
line-height: 20px;
// Style @mentions
.mention {
color: var(--button-bg);
font-weight: 600;
}
}
.recap-item-badge {
flex-shrink: 0;
padding: 0 4px;
border-radius: var(--radius-s);
margin-top: 2px;
background-color: rgba(var(--button-bg-rgb), 0.08);
color: var(--button-bg);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1px;
line-height: 16px;
&.recap-item-badge-link {
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
&:hover {
background-color: rgba(var(--button-bg-rgb), 0.16);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
}
}
}
}
}
// Recap Processing Styles
.recap-processing {
overflow: hidden;
padding: 24px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
border-radius: var(--radius-s);
background-color: var(--center-channel-bg);
transition: all 0.2s;
.recap-processing-header {
margin-bottom: 24px;
.recap-processing-title {
margin: 0 0 8px;
color: var(--center-channel-color);
font-family: 'Metropolis', sans-serif;
font-size: 22px;
font-weight: 600;
line-height: 28px;
}
.recap-processing-subtitle {
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
}
.recap-processing-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
.recap-processing-spinner {
margin-bottom: 16px;
.spinner-large {
width: 48px;
height: 48px;
border: 4px solid rgba(var(--center-channel-color-rgb), 0.12);
border-radius: var(--radius-full);
border-top-color: var(--button-bg);
animation: spin 1s linear infinite;
}
}
.recap-processing-message {
margin: 0;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 14px;
line-height: 20px;
}
}
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {Redirect} from 'react-router-dom';
import {PlusIcon} from '@mattermost/compass-icons/components';
import {getAgents} from 'mattermost-redux/actions/agents';
import {getRecaps} from 'mattermost-redux/actions/recaps';
import {getUnreadRecaps, getReadRecaps} from 'mattermost-redux/selectors/entities/recaps';
import {openModal} from 'actions/views/modals';
import useGetAgentsBridgeEnabled from 'components/common/hooks/useGetAgentsBridgeEnabled';
import useGetFeatureFlagValue from 'components/common/hooks/useGetFeatureFlagValue';
import CreateRecapModal from 'components/create_recap_modal';
import {ModalIdentifiers} from 'utils/constants';
import RecapsList from './recaps_list';
import './recaps.scss';
const Recaps = () => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const [activeTab, setActiveTab] = useState<'unread' | 'read'>('unread');
const enableAIRecaps = useGetFeatureFlagValue('EnableAIRecaps');
const agentsBridgeEnabled = useGetAgentsBridgeEnabled();
const unreadRecaps = useSelector(getUnreadRecaps);
const readRecaps = useSelector(getReadRecaps);
useEffect(() => {
dispatch(getRecaps(0, 60));
dispatch(getAgents());
}, [dispatch]);
// Redirect if feature flag is disabled
if (enableAIRecaps !== 'true') {
return <Redirect to='/'/>;
}
const handleAddRecap = () => {
dispatch(openModal({
modalId: ModalIdentifiers.CREATE_RECAP_MODAL,
dialogType: CreateRecapModal,
}));
};
const displayedRecaps = activeTab === 'unread' ? unreadRecaps : readRecaps;
return (
<div className='recaps-container'>
<div className='recaps-header'>
<div className='recaps-header-left'>
<div className='recaps-title-container'>
<i className='icon icon-robot-outline'/>
<h1 className='recaps-title'>
{formatMessage({id: 'recaps.title', defaultMessage: 'Recaps'})}
</h1>
</div>
<div className='recaps-tabs'>
<button
className={`recaps-tab ${activeTab === 'unread' ? 'active' : ''}`}
onClick={() => setActiveTab('unread')}
>
{formatMessage({id: 'recaps.unreadTab', defaultMessage: 'Unread'})}
</button>
<button
className={`recaps-tab ${activeTab === 'read' ? 'active' : ''}`}
onClick={() => setActiveTab('read')}
>
{formatMessage({id: 'recaps.readTab', defaultMessage: 'Read'})}
</button>
</div>
</div>
<button
className='btn btn-tertiary recap-add-button'
onClick={handleAddRecap}
disabled={agentsBridgeEnabled === false}
title={agentsBridgeEnabled ? undefined : formatMessage({id: 'recaps.addRecap.disabled', defaultMessage: 'Agents Bridge is not enabled'})}
>
<PlusIcon size={12}/>
{formatMessage({id: 'recaps.addRecap', defaultMessage: 'Add a recap'})}
</button>
</div>
<div className='recaps-content'>
<RecapsList recaps={displayedRecaps}/>
</div>
</div>
);
};
export default Recaps;

View file

@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {Recap} from '@mattermost/types/recaps';
import {RecapStatus} from '@mattermost/types/recaps';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import RecapsList from './recaps_list';
jest.mock('mattermost-redux/actions/recaps', () => ({
pollRecapStatus: jest.fn(() => ({type: 'POLL_RECAP_STATUS'})),
}));
describe('RecapsList', () => {
const mockCompletedRecaps: Recap[] = [
{
id: 'recap1',
title: 'Morning Standup',
user_id: 'user1',
bot_id: 'bot1',
status: RecapStatus.COMPLETED,
create_at: 1000,
update_at: 1000,
delete_at: 0,
read_at: 0,
channels: [],
total_message_count: 5,
},
{
id: 'recap2',
title: 'Weekly Review',
user_id: 'user1',
bot_id: 'bot1',
status: RecapStatus.COMPLETED,
create_at: 2000,
update_at: 2000,
delete_at: 0,
read_at: 0,
channels: [],
total_message_count: 10,
},
];
beforeEach(() => {
jest.clearAllMocks();
});
test('should render empty state when no recaps', () => {
renderWithContext(<RecapsList recaps={[]}/>);
expect(screen.getByText("You're all caught up")).toBeInTheDocument();
expect(screen.getByText("You don't have any recaps yet. Create one to get started.")).toBeInTheDocument();
});
test('should render recap items when recaps exist', () => {
renderWithContext(<RecapsList recaps={mockCompletedRecaps}/>);
expect(screen.getByText('Morning Standup')).toBeInTheDocument();
expect(screen.getByText('Weekly Review')).toBeInTheDocument();
});
test('should show "all caught up" message at the bottom', () => {
renderWithContext(<RecapsList recaps={mockCompletedRecaps}/>);
const allCaughtUpMessages = screen.getAllByText("You're all caught up");
expect(allCaughtUpMessages.length).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect, useRef} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {Recap} from '@mattermost/types/recaps';
import {RecapStatus} from '@mattermost/types/recaps';
import {getRecap} from 'mattermost-redux/actions/recaps';
import RecapItem from './recap_item';
type Props = {
recaps: Recap[];
};
const RecapsList = ({recaps}: Props) => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const [expandedRecapIds, setExpandedRecapIds] = useState<Set<string>>(new Set());
const previousRecapStatuses = useRef<Map<string, string>>(new Map());
// Auto-expand recaps when they finish processing
useEffect(() => {
const newExpanded = new Set(expandedRecapIds);
let hasChanges = false;
recaps.forEach((recap) => {
const previousStatus = previousRecapStatuses.current.get(recap.id);
const isProcessing = previousStatus === RecapStatus.PENDING || previousStatus === RecapStatus.PROCESSING;
const isCompleted = recap.status === RecapStatus.COMPLETED;
// If recap just finished processing, expand it and fetch details
if (isProcessing && isCompleted && !expandedRecapIds.has(recap.id)) {
newExpanded.add(recap.id);
hasChanges = true;
dispatch(getRecap(recap.id));
}
// Update the previous status
previousRecapStatuses.current.set(recap.id, recap.status);
});
if (hasChanges) {
setExpandedRecapIds(newExpanded);
}
}, [recaps, expandedRecapIds, dispatch]);
const toggleRecap = (recapId: string) => {
const newExpanded = new Set(expandedRecapIds);
if (newExpanded.has(recapId)) {
newExpanded.delete(recapId);
} else {
newExpanded.add(recapId);
// Fetch full recap with channels if not already loaded
dispatch(getRecap(recapId));
}
setExpandedRecapIds(newExpanded);
};
if (recaps.length === 0) {
return (
<div className='recaps-empty-state'>
<div className='empty-state-icon'>
<i className='icon icon-check-circle'/>
</div>
<h2 className='empty-state-title'>
{formatMessage({id: 'recaps.emptyState.title', defaultMessage: "You're all caught up"})}
</h2>
<p className='empty-state-description'>
{formatMessage({id: 'recaps.emptyState.description', defaultMessage: "You don't have any recaps yet. Create one to get started."})}
</p>
</div>
);
}
return (
<div className='recaps-list'>
{recaps.map((recap) => (
<RecapItem
key={recap.id}
recap={recap}
isExpanded={expandedRecapIds.has(recap.id)}
onToggle={() => toggleRecap(recap.id)}
/>
))}
<div className='recap-all-caught-up'>
<i className='icon icon-check-circle'/>
<span>{formatMessage({id: 'recaps.allCaughtUp', defaultMessage: "You're all caught up"})}</span>
</div>
</div>
);
};
export default RecapsList;

View file

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

View file

@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.SidebarRecaps {
margin-bottom: 12px;
.SidebarChannel {
height: 32px;
padding: 0;
&.active {
background-color: rgba(var(--sidebar-text-rgb), 0.08);
.SidebarLink::before {
position: absolute;
top: 0;
left: 0;
width: 2px;
height: 100%;
border-radius: 0 var(--radius-s) var(--radius-s) 0;
background-color: var(--sidebar-text-active-border);
content: '';
}
}
&:hover:not(.active) {
background-color: rgba(var(--sidebar-text-rgb), 0.04);
}
}
.SidebarLink {
position: relative;
display: flex;
height: 100%;
align-items: center;
padding: 0 16px 0 18px;
color: rgba(var(--sidebar-text-rgb), 0.72);
text-decoration: none;
.icon {
display: flex;
align-items: center;
padding: 3px;
margin-right: 4px;
font-size: 18px;
opacity: 0.64;
}
.SidebarChannelLinkLabel_wrapper {
min-width: 0;
flex: 1;
padding: 2px 4px;
}
.SidebarChannelLinkLabel {
overflow: hidden;
font-size: 14px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.active .SidebarLink {
color: var(--sidebar-text);
.icon {
opacity: 1;
}
.SidebarChannelLinkLabel {
font-weight: 600;
}
}
}

View file

@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {Link, useLocation, matchPath, useRouteMatch} from 'react-router-dom';
import {CreationOutlineIcon} from '@mattermost/compass-icons/components';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import useGetFeatureFlagValue from 'components/common/hooks/useGetFeatureFlagValue';
import './recaps_link.scss';
const RecapsLink = () => {
const {url} = useRouteMatch();
const {pathname} = useLocation();
const currentTeamId = useSelector(getCurrentTeamId);
const currentUserId = useSelector(getCurrentUserId);
const enableAIRecaps = useGetFeatureFlagValue('EnableAIRecaps');
const inRecaps = matchPath(pathname, {path: '/:team/recaps/:recapId?'}) != null;
if (!currentTeamId || !currentUserId || enableAIRecaps !== 'true') {
return null;
}
return (
<ul className='SidebarRecaps NavGroupContent nav nav-pills__container'>
<li
id={'sidebar-recaps-button'}
className={classNames('SidebarChannel', {
active: inRecaps,
})}
tabIndex={-1}
>
<Link
to={`${url}/recaps`}
id='sidebarItem_recaps'
draggable='false'
className='SidebarLink sidebar-item'
tabIndex={0}
>
<span className='icon'>
<CreationOutlineIcon size={18}/>
</span>
<div className='SidebarChannelLinkLabel_wrapper'>
<span className='SidebarChannelLinkLabel sidebar-item__name'>
<FormattedMessage
id='recaps.sidebarLink'
defaultMessage='Recaps'
/>
</span>
</div>
</Link>
</li>
</ul>
);
};
export default RecapsLink;

View file

@ -503,7 +503,7 @@ export default class Root extends React.PureComponent<Props, State> {
export function doesRouteBelongToTeamControllerRoutes(pathname: RouteComponentProps['location']['pathname']): boolean {
// Note: we have specifically added admin_console to the negative lookahead as admin_console can have integrations as subpaths (admin_console/integrations/bot_accounts)
// and we don't want to treat those as team controller routes.
const TEAM_CONTROLLER_PATH_PATTERN = new RegExp(`^/(?!admin_console)([a-z0-9\\-_]+)/(channels|messages|threads|drafts|integrations|emoji|${SCHEDULED_POST_URL_SUFFIX})(/.*)?$`);
const TEAM_CONTROLLER_PATH_PATTERN = new RegExp(`^/(?!admin_console)([a-z0-9\\-_]+)/(channels|messages|threads|recaps|drafts|integrations|emoji|${SCHEDULED_POST_URL_SUFFIX})(/.*)?$`);
return TEAM_CONTROLLER_PATH_PATTERN.test(pathname);
}

View file

@ -4,6 +4,7 @@ exports[`SidebarList should match snapshot 1`] = `
<Fragment>
<GlobalThreadsLink />
<DraftsLink />
<RecapsLink />
<div
aria-label="channel sidebar region"
className="SidebarNavContainer a11y__region"

View file

@ -30,6 +30,7 @@ import type {StaticPage} from 'types/store/lhs';
const DraftsLink = makeAsyncComponent('DraftsLink', lazy(() => import('components/drafts/drafts_link/drafts_link')));
const GlobalThreadsLink = makeAsyncComponent('GlobalThreadsLink', lazy(() => import('components/threading/global_threads_link')));
const RecapsLink = makeAsyncComponent('RecapsLink', lazy(() => import('components/recaps_link')));
const UnreadChannelIndicator = makeAsyncComponent('UnreadChannelIndicator', lazy(() => import('../unread_channel_indicator')));
const UnreadChannels = makeAsyncComponent('UnreadChannels', lazy(() => import('../unread_channels')));
@ -495,6 +496,7 @@ export class SidebarList extends React.PureComponent<Props, State> {
<>
<GlobalThreadsLink/>
<DraftsLink/>
<RecapsLink/>
<div
id='sidebar-left'
role='application'

View file

@ -73,7 +73,7 @@ const Control = <T extends OptionType>(props: ControlProps<T, false>) => (
</div>
);
const Option = <T extends OptionType>(props: OptionProps<T, false, GroupBase<T>>) => (
const Option = <T extends OptionType>(props: OptionProps<T, false, GroupBase<T>>): JSX.Element => (
<div
className={classNames('DropdownInput__option', {
selected: props.isSelected,

View file

@ -4559,6 +4559,8 @@
"generic_icons.warning": "Warning Icon",
"generic_modal.cancel": "Cancel",
"generic_modal.confirm": "Confirm",
"generic_modal.next": "Next",
"generic_modal.previous": "Previous",
"generic.close": "Close",
"generic.done": "Done",
"generic.enterprise_feature": "Enterprise Feature",
@ -5490,6 +5492,52 @@
"reaction.usersAndOthersReacted": "{users} and {otherUsers, number} other {otherUsers, plural, one {user} other {users}}",
"reaction.usersReacted": "{users} and {lastUser}",
"reaction.you": "You",
"recaps.actionItems": "Action items:",
"recaps.addRecap": "Add a recap",
"recaps.addRecap.disabled": "Agents Bridge is not enabled",
"recaps.allCaughtUp": "You're all caught up",
"recaps.channelMenu.ariaLabel": "Options for {channelName}",
"recaps.defaultAgent": "Copilot",
"recaps.delete.confirm.button": "Delete",
"recaps.delete.confirm.message": "Are you sure you want to delete <strong>{title}</strong>? This action cannot be undone.",
"recaps.delete.confirm.title": "Delete recap?",
"recaps.emptyState.description": "You don't have any recaps yet. Create one to get started.",
"recaps.emptyState.title": "You're all caught up",
"recaps.generatedBy": "Generated by {agentName}",
"recaps.highlights": "Highlights",
"recaps.markRead": "Mark read",
"recaps.menu.ariaLabel": "Options for {title}",
"recaps.menu.markAllChannelsRead": "Mark all channels as read",
"recaps.menu.markChannelRead": "Mark this channel as read",
"recaps.menu.openChannel": "Open channel",
"recaps.menu.regenerateRecap": "Regenerate this recap",
"recaps.messageCount": "Recapped {count} {count, plural, one {message} other {messages}}",
"recaps.modal.allChannels": "ALL CHANNELS",
"recaps.modal.allUnreads": "Recap all my unreads",
"recaps.modal.allUnreadsDesc": "Copilot will create a recap of all unreads across your channels.",
"recaps.modal.error.createFailed": "Failed to create recap. Please try again.",
"recaps.modal.error.noBot": "Please select an AI agent.",
"recaps.modal.error.noChannels": "Please select at least one channel.",
"recaps.modal.nameLabel": "Give your recap a name",
"recaps.modal.namePlaceholder": "Give your recap a name",
"recaps.modal.noChannels": "No channels found",
"recaps.modal.noUnreadsAvailable": "No unread channels available",
"recaps.modal.noUnreadsAvailableHint": "You currently have no unread messages in any channels",
"recaps.modal.recommended": "RECOMMENDED",
"recaps.modal.selectChannels": "Select the channels you want to include",
"recaps.modal.selectedChannels": "Recap selected channels",
"recaps.modal.selectedChannelsDesc": "Choose the channels you would like included in your recap",
"recaps.modal.startRecap": "Start recap",
"recaps.modal.summaryTitle": "The following channels will be included in your recap",
"recaps.modal.title": "Set up your recap",
"recaps.modal.typeLabel": "What type of recap would you like?",
"recaps.processing.message": "We're working on your recap. Check back shortly",
"recaps.processing.subtitle": "Recap created. You'll receive a summary shortly",
"recaps.readTab": "Read",
"recaps.sidebarLink": "Recaps",
"recaps.status.failed": "Failed",
"recaps.title": "Recaps",
"recaps.unreadTab": "Unread",
"remove_flag_post_confirm_modal.optional_comment.title": "Comment (optional)",
"remove_flag_post_confirm_modal.required_comment.title": "Comment (required)",
"remove_group_confirm_button": "Yes, Remove Group and {memberCount, plural, one {Member} other {Members}}",

View file

@ -26,6 +26,7 @@ import PlaybookType from './playbooks';
import PluginTypes from './plugins';
import PostTypes from './posts';
import PreferenceTypes from './preferences';
import RecapTypes from './recaps';
import RoleTypes from './roles';
import SchemeTypes from './schemes';
import ScheduledPostTypes from './scheudled_posts';
@ -44,6 +45,7 @@ export {
PostTypes,
FileTypes,
PreferenceTypes,
RecapTypes,
IntegrationTypes,
EmojiTypes,
AdminTypes,

View file

@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import keyMirror from 'mattermost-redux/utils/key_mirror';
export default keyMirror({
CREATE_RECAP_REQUEST: null,
CREATE_RECAP_SUCCESS: null,
CREATE_RECAP_FAILURE: null,
RECEIVED_RECAP: null,
RECEIVED_RECAPS: null,
GET_RECAP_REQUEST: null,
GET_RECAP_SUCCESS: null,
GET_RECAP_FAILURE: null,
GET_RECAPS_REQUEST: null,
GET_RECAPS_SUCCESS: null,
GET_RECAPS_FAILURE: null,
MARK_RECAP_READ_REQUEST: null,
MARK_RECAP_READ_SUCCESS: null,
MARK_RECAP_READ_FAILURE: null,
DELETE_RECAP_REQUEST: null,
DELETE_RECAP_SUCCESS: null,
DELETE_RECAP_FAILURE: null,
REGENERATE_RECAP_REQUEST: null,
REGENERATE_RECAP_SUCCESS: null,
REGENERATE_RECAP_FAILURE: null,
});

View file

@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Recap} from '@mattermost/types/recaps';
import {RecapTypes} from 'mattermost-redux/action_types';
import {logError} from 'mattermost-redux/actions/errors';
import {Client4} from 'mattermost-redux/client';
import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
import {bindClientFunc, forceLogoutIfNecessary} from './helpers';
export function createRecap(title: string, channelIds: string[], agentId: string): ActionFuncAsync<Recap> {
return bindClientFunc({
clientFunc: () => Client4.createRecap({title, channel_ids: channelIds, agent_id: agentId}),
onRequest: RecapTypes.CREATE_RECAP_REQUEST,
onSuccess: [RecapTypes.CREATE_RECAP_SUCCESS, RecapTypes.RECEIVED_RECAP],
onFailure: RecapTypes.CREATE_RECAP_FAILURE,
});
}
export function getRecaps(page = 0, perPage = 60): ActionFuncAsync<Recap[]> {
return bindClientFunc({
clientFunc: () => Client4.getRecaps(page, perPage),
onRequest: RecapTypes.GET_RECAPS_REQUEST,
onSuccess: [RecapTypes.GET_RECAPS_SUCCESS, RecapTypes.RECEIVED_RECAPS],
onFailure: RecapTypes.GET_RECAPS_FAILURE,
});
}
export function getRecap(recapId: string): ActionFuncAsync<Recap> {
return bindClientFunc({
clientFunc: () => Client4.getRecap(recapId),
onRequest: RecapTypes.GET_RECAP_REQUEST,
onSuccess: [RecapTypes.GET_RECAP_SUCCESS, RecapTypes.RECEIVED_RECAP],
onFailure: RecapTypes.GET_RECAP_FAILURE,
});
}
export function markRecapAsRead(recapId: string): ActionFuncAsync<Recap> {
return bindClientFunc({
clientFunc: () => Client4.markRecapAsRead(recapId),
onRequest: RecapTypes.MARK_RECAP_READ_REQUEST,
onSuccess: [RecapTypes.MARK_RECAP_READ_SUCCESS, RecapTypes.RECEIVED_RECAP],
onFailure: RecapTypes.MARK_RECAP_READ_FAILURE,
});
}
export function regenerateRecap(recapId: string): ActionFuncAsync<Recap> {
return bindClientFunc({
clientFunc: () => Client4.regenerateRecap(recapId),
onRequest: RecapTypes.REGENERATE_RECAP_REQUEST,
onSuccess: [RecapTypes.REGENERATE_RECAP_SUCCESS, RecapTypes.RECEIVED_RECAP],
onFailure: RecapTypes.REGENERATE_RECAP_FAILURE,
});
}
export function deleteRecap(recapId: string): ActionFuncAsync {
return async (dispatch, getState) => {
dispatch({type: RecapTypes.DELETE_RECAP_REQUEST, data: recapId});
try {
await Client4.deleteRecap(recapId);
dispatch({
type: RecapTypes.DELETE_RECAP_SUCCESS,
data: {recapId},
});
return {data: true};
} catch (error) {
dispatch(logError(error));
forceLogoutIfNecessary(error, dispatch, getState);
dispatch({
type: RecapTypes.DELETE_RECAP_FAILURE,
error,
});
return {error};
}
};
}

View file

@ -22,6 +22,7 @@ import jobs from './jobs';
import limits from './limits';
import posts from './posts';
import preferences from './preferences';
import recaps from './recaps';
import roles from './roles';
import scheduledPosts from './scheduled_posts';
import schemes from './schemes';
@ -43,6 +44,7 @@ export default combineReducers({
posts,
files,
preferences,
recaps,
typing,
integrations,
emojis,

View file

@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Recap} from '@mattermost/types/recaps';
import type {MMReduxAction} from 'mattermost-redux/action_types';
import {RecapTypes, UserTypes} from 'mattermost-redux/action_types';
export type RecapsState = {
byId: Record<string, Recap>;
allIds: string[];
};
const initialState: RecapsState = {
byId: {},
allIds: [],
};
export default function recapsReducer(state = initialState, action: MMReduxAction): RecapsState {
switch (action.type) {
case RecapTypes.RECEIVED_RECAP: {
const recap = action.data as Recap;
const nextState = {...state};
nextState.byId = {
...state.byId,
[recap.id]: recap,
};
if (!state.allIds.includes(recap.id)) {
nextState.allIds = [...state.allIds, recap.id];
}
return nextState;
}
case RecapTypes.RECEIVED_RECAPS: {
const recaps = action.data as Recap[];
const nextState = {...state};
const newById: Record<string, Recap> = {...state.byId};
const newAllIds = new Set(state.allIds);
recaps.forEach((recap) => {
newById[recap.id] = recap;
newAllIds.add(recap.id);
});
nextState.byId = newById;
nextState.allIds = Array.from(newAllIds);
return nextState;
}
case RecapTypes.DELETE_RECAP_SUCCESS: {
const {recapId} = action.data as {recapId: string};
const nextState = {...state};
const newById = {...state.byId};
delete newById[recapId];
nextState.byId = newById;
nextState.allIds = state.allIds.filter((id) => id !== recapId);
return nextState;
}
case UserTypes.LOGOUT_SUCCESS:
return initialState;
default:
return state;
}
}

View file

@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Recap} from '@mattermost/types/recaps';
import {RecapStatus} from '@mattermost/types/recaps';
import type {GlobalState} from '@mattermost/types/store';
import {createSelector} from 'mattermost-redux/selectors/create_selector';
export function getAllRecaps(state: GlobalState): Recap[] {
const {byId, allIds} = state.entities.recaps;
return allIds.map((id) => byId[id]);
}
export function getRecap(state: GlobalState, recapId: string): Recap | undefined {
return state.entities.recaps.byId[recapId] || undefined;
}
export const getRecapsByStatus = createSelector(
'getRecapsByStatus',
getAllRecaps,
(_state: GlobalState, status: RecapStatus) => status,
(recaps, status) => {
return recaps.filter((recap) => recap.status === status);
},
);
export const getSortedRecaps = createSelector(
'getSortedRecaps',
getAllRecaps,
(recaps) => {
return [...recaps].sort((a, b) => b.create_at - a.create_at);
},
);
export const getCompletedRecaps = createSelector(
'getCompletedRecaps',
getAllRecaps,
(recaps) => {
return recaps.filter((recap) => recap.status === RecapStatus.COMPLETED).sort((a, b) => b.create_at - a.create_at);
},
);
export const getPendingRecaps = createSelector(
'getPendingRecaps',
getAllRecaps,
(recaps) => {
return recaps.filter((recap) => recap.status === RecapStatus.PENDING || recap.status === RecapStatus.PROCESSING);
},
);
export const getUnreadRecaps = createSelector(
'getUnreadRecaps',
getAllRecaps,
(recaps) => {
return recaps.filter((recap) => recap.read_at === 0).sort((a, b) => b.create_at - a.create_at);
},
);
export const getReadRecaps = createSelector(
'getReadRecaps',
getAllRecaps,
(recaps) => {
return recaps.filter((recap) => recap.read_at > 0).sort((a, b) => b.read_at - a.read_at);
},
);

View file

@ -102,6 +102,10 @@ const state: GlobalState = {
counts: {},
countsIncludingDirect: {},
},
recaps: {
byId: {},
allIds: [],
},
preferences: {
myPreferences: {},
userPreferences: {},

View file

@ -480,6 +480,7 @@ export const ModalIdentifiers = {
ATTRIBUTE_MODAL_SAML: 'attribute_modal_saml',
FLAG_POST: 'flag_post',
REMOVE_FLAGGED_POST: 'remove_flagged_post',
CREATE_RECAP_MODAL: 'create_recap_modal',
};
export const UserStatuses = {
@ -701,6 +702,7 @@ export const SocketEvents = {
CPA_FIELD_DELETED: 'custom_profile_attributes_field_deleted',
CPA_VALUES_UPDATED: 'custom_profile_attributes_values_updated',
CONTENT_FLAGGING_REPORT_VALUE_CHANGED: 'content_flagging_report_value_updated',
RECAP_UPDATED: 'recap_updated',
};
export const TutorialSteps = {

View file

@ -120,6 +120,7 @@ import type {
PropertyValue,
} from '@mattermost/types/properties';
import type {Reaction} from '@mattermost/types/reactions';
import type {Recap, CreateRecapRequest} from '@mattermost/types/recaps';
import type {RemoteCluster, RemoteClusterAcceptInvite, RemoteClusterPatch, RemoteClusterWithPassword} from '@mattermost/types/remote_clusters';
import type {UserReport, UserReportFilter, UserReportOptions} from '@mattermost/types/reports';
import type {Role} from '@mattermost/types/roles';
@ -452,6 +453,10 @@ export default class Client4 {
return `${this.getBaseRoute()}/jobs`;
}
getRecapsRoute() {
return `${this.getBaseRoute()}/recaps`;
}
getPluginsRoute() {
return `${this.getBaseRoute()}/plugins`;
}
@ -3296,6 +3301,49 @@ export default class Client4 {
);
};
// Recaps Routes
createRecap = (request: CreateRecapRequest) => {
return this.doFetch<Recap>(
`${this.getRecapsRoute()}`,
{method: 'post', body: JSON.stringify(request)},
);
};
getRecaps = (page = 0, perPage = PER_PAGE_DEFAULT) => {
return this.doFetch<Recap[]>(
`${this.getRecapsRoute()}${buildQueryString({page, per_page: perPage})}`,
{method: 'get'},
);
};
getRecap = (recapId: string) => {
return this.doFetch<Recap>(
`${this.getRecapsRoute()}/${recapId}`,
{method: 'get'},
);
};
markRecapAsRead = (recapId: string) => {
return this.doFetch<Recap>(
`${this.getRecapsRoute()}/${recapId}/read`,
{method: 'post'},
);
};
regenerateRecap = (recapId: string) => {
return this.doFetch<Recap>(
`${this.getRecapsRoute()}/${recapId}/regenerate`,
{method: 'post'},
);
};
deleteRecap = (recapId: string) => {
return this.doFetch<StatusOK>(
`${this.getRecapsRoute()}/${recapId}`,
{method: 'delete'},
);
};
// Admin Routes
getLogs = (logFilter: LogFilterQuery) => {

View file

@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type Recap = {
id: string;
user_id: string;
title: string;
create_at: number;
update_at: number;
delete_at: number;
read_at: number;
total_message_count: number;
status: RecapStatus;
bot_id: string;
channels?: RecapChannel[];
};
export type RecapChannel = {
id: string;
recap_id: string;
channel_id: string;
channel_name: string;
highlights: string[];
action_items: string[];
source_post_ids: string[];
create_at: number;
};
export type CreateRecapRequest = {
title: string;
channel_ids: string[];
agent_id: string;
};
export enum RecapStatus {
PENDING = 'pending',
PROCESSING = 'processing',
COMPLETED = 'completed',
FAILED = 'failed',
}

View file

@ -19,6 +19,7 @@ import type {JobsState} from './jobs';
import type {LimitsState} from './limits';
import type {PostsState} from './posts';
import type {PreferenceType} from './preferences';
import type {Recap} from './recaps';
import type {
AdminRequestsStatuses, ChannelsRequestsStatuses,
FilesRequestsStatuses, GeneralRequestsStatuses,
@ -45,6 +46,10 @@ export type GlobalState = {
channelBookmarks: ChannelBookmarksState;
posts: PostsState;
threads: ThreadsState;
recaps: {
byId: Record<string, Recap>;
allIds: string[];
};
agents: {
agents: Array<{
id: string;