+
{toasts}
);
diff --git a/webapp/channels/src/plugins/ai/components/assets/buttons.tsx b/webapp/channels/src/plugins/ai/components/assets/buttons.tsx
new file mode 100644
index 0000000000..d2c0d12823
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/assets/buttons.tsx
@@ -0,0 +1,224 @@
+// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
+// See License for license information.
+
+import styled from 'styled-components';
+
+export const Button = styled.button`
+ display: inline-flex;
+ align-items: center;
+ height: 40px;
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ color: rgba(var(--center-channel-color-rgb), 0.72);
+ border-radius: 4px;
+ border: 0px;
+ font-weight: 600;
+ font-size: 14px;
+ padding: 0 20px;
+ position: relative;
+ justify-content: center;
+
+ transition: all 0.15s ease-out;
+
+ &:hover{
+ background: rgba(var(--center-channel-color-rgb), 0.12);
+ }
+
+ &&, &&:focus {
+ text-decoration: none;
+ }
+
+ &&:hover:not([disabled]) {
+ text-decoration: none;
+ }
+
+ &:disabled {
+ color: rgba(var(--center-channel-color-rgb), 0.32);
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ }
+
+ i {
+ display: flex;
+ font-size: 18px;
+ }
+`;
+
+export const PrimaryButton = styled(Button)`
+ &&, &&:focus {
+ background: var(--button-bg);
+ color: var(--button-color);
+ white-space: nowrap;
+ }
+
+ &:active:not([disabled]) {
+ background: rgba(var(--button-bg-rgb), 0.8);
+ }
+
+ &:before {
+ content: '';
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ transition: all 0.15s ease-out;
+ position: absolute;
+ background: rgba(var(--center-channel-color-rgb), 0.16);
+ opacity: 0;
+ border-radius: 4px;
+ }
+
+ &&:hover:not([disabled]) {
+ color: var(--button-color);
+ background: var(--button-bg);
+ &:before {
+ opacity: 1;
+ }
+ }
+
+ &:disabled {
+ color: rgba(var(--center-channel-color-rgb), 0.32);
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ }
+`;
+
+export const SubtlePrimaryButton = styled(Button)`
+ background: rgba(var(--button-bg-rgb), 0.08);
+ color: var(--button-bg);
+ &:hover,
+ &:active {
+ background: rgba(var(--button-bg-rgb), 0.12);
+ }
+`;
+
+export const TertiaryButton = styled.button`
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 40px;
+ border-radius: 4px;
+ border: 0px;
+ font-weight: 600;
+ font-size: 14px;
+ padding: 0 20px;
+
+ color: var(--button-bg);
+ background: rgba(var(--button-bg-rgb), 0.08);
+
+ &:disabled {
+ color: rgba(var(--center-channel-color-rgb), 0.32);
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ }
+
+ &:hover:enabled {
+ background: rgba(var(--button-bg-rgb), 0.12);
+ }
+
+ &:active:enabled {
+ background: rgba(var(--button-bg-rgb), 0.16);
+ }
+
+ i {
+ display: flex;
+ font-size: 18px;
+
+ &:before {
+ margin: 0 7px 0 0;
+ }
+ }
+`;
+
+export const InvertedTertiaryButton = styled(Button)`
+ transition: all 0.15s ease-out;
+
+ && {
+ color: var(--button-bg-rgb);
+ background-color: rgba(var(--button-color-rgb), 0.08);
+ }
+
+ &&:hover:not([disabled]) {
+ color: var(--button-bg-rgb);
+ background: rgba(var(--button-bg-rgb), 0.12);
+ }
+
+ &&:active:not([disabled]) {
+ color: var(--button-bg-rgb);
+ background: rgba(var(--button-bg-rgb), 0.16);
+ }
+
+ &&:focus:not([disabled]) {
+ color: var(--button-bg-rgb);
+ background-color: rgba(var(--button-color-rgb), 0.08);
+ box-shadow: inset 0px 0px 0px 2px var(--sidebar-text-active-border-rgb);
+ }
+`;
+
+export const SecondaryButton = styled(TertiaryButton)`
+ background: var(--button-color-rgb);
+ border: 1px solid var(--button-bg);
+
+
+ &:disabled {
+ color: rgba(var(--center-channel-color-rgb), 0.32);
+ background: transparent;
+ border: 1px solid rgba(var(--center-channel-color-rgb), 0.32);
+ }
+`;
+
+export const DestructiveButton = styled.button`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+
+ font-weight: 600;
+ font-size: 14px;
+
+ padding: 0 20px;
+
+ border-radius: 4px;
+ border: 0px;
+
+ background: var(--dnd-indicator);
+ color: var(--button-color);
+
+ :hover:enabled {
+ background: linear-gradient(0deg, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.08)), var(--dnd-indicator);
+ }
+
+ :active, :hover:active {
+ background: linear-gradient(0deg, rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.16)), var(--dnd-indicator);
+ }
+
+ :disabled {
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ }
+`;
+
+export const ButtonIcon = styled.button`
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: none;
+ background: transparent;
+ border-radius: 4px;
+ color: rgba(var(--center-channel-color-rgb), 0.56);
+ fill: rgba(var(--center-channel-color-rgb), 0.56);
+ font-size: 1.6rem;
+
+ &:hover {
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ color: rgba(var(--center-channel-color-rgb), 0.72);
+ fill: rgba(var(--center-channel-color-rgb), 0.72);
+ }
+
+ &:active,
+ &--active,
+ &--active:hover {
+ background: rgba(var(--button-bg-rgb), 0.08);
+ color: var(--button-bg);
+ fill: var(--button-bg);
+ }
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`;
diff --git a/webapp/channels/src/plugins/ai/components/assets/icon-regenerate.tsx b/webapp/channels/src/plugins/ai/components/assets/icon-regenerate.tsx
new file mode 100644
index 0000000000..31a11776a6
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/assets/icon-regenerate.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+
+import Svg from '../svg';
+
+const IconRegenerate = () => (
+
+);
+
+export default IconRegenerate;
diff --git a/webapp/channels/src/plugins/ai/components/assets/icon_ai.tsx b/webapp/channels/src/plugins/ai/components/assets/icon_ai.tsx
new file mode 100644
index 0000000000..13c9d9f0a8
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/assets/icon_ai.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+
+import Svg from '../svg';
+
+const IconAI = (props: {className?: string}) => (
+
+
+);
+
+export default IconAI;
diff --git a/webapp/channels/src/plugins/ai/components/assets/icon_cancel.tsx b/webapp/channels/src/plugins/ai/components/assets/icon_cancel.tsx
new file mode 100644
index 0000000000..83a6751eda
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/assets/icon_cancel.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+
+import Svg from '../svg';
+
+const IconCancel = () => (
+
+);
+
+export default IconCancel;
diff --git a/webapp/channels/src/plugins/ai/components/assets/icon_sparkle_checkmark.tsx b/webapp/channels/src/plugins/ai/components/assets/icon_sparkle_checkmark.tsx
new file mode 100644
index 0000000000..37f77884c9
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/assets/icon_sparkle_checkmark.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+
+import Svg from '../svg';
+
+const IconSparkleCheckmark = (props: {className?: string}) => (
+
+);
+
+export default IconSparkleCheckmark;
diff --git a/webapp/channels/src/plugins/ai/components/assets/icon_thread_summarization.tsx b/webapp/channels/src/plugins/ai/components/assets/icon_thread_summarization.tsx
new file mode 100644
index 0000000000..0fce3dba3b
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/assets/icon_thread_summarization.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+
+import Svg from '../svg'
+
+const IconThreadSummarization = () => (
+
+
+);
+
+export default IconThreadSummarization;
diff --git a/webapp/channels/src/plugins/ai/components/bot_slector.tsx b/webapp/channels/src/plugins/ai/components/bot_slector.tsx
new file mode 100644
index 0000000000..256529948a
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/bot_slector.tsx
@@ -0,0 +1,140 @@
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+
+import styled from 'styled-components';
+
+import {CheckIcon, ChevronDownIcon} from '@infomaniak/compass-icons/components';
+import {Client4} from 'mattermost-redux/client';
+
+import DotMenu, {DotMenuButton, DropdownMenu, DropdownMenuItem} from './dot_menu';
+import {GrayPill} from './pill';
+
+interface LLMBot {
+ id: string;
+ displayName: string;
+ username: string;
+ lastIconUpdate: number;
+ dmChannelID: string;
+}
+
+type DropdownBotSelectorProps = {
+ bots: LLMBot[]
+ activeBot: LLMBot | null
+ setActiveBot: (bot: LLMBot) => void
+}
+
+export const DropdownBotSelector = (props: DropdownBotSelectorProps) => {
+ return (
+
+ <>
+ {/*
+
+ */}
+
+ {props.activeBot?.displayName}
+
+
+ >
+
+ );
+};
+
+const BotPill = styled(GrayPill)`
+ font-size: 12px;
+ padding: 2px 6px;
+ gap: 0;
+`;
+
+const BotSelectorContainer = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 8px;
+
+ margin: 8px 16px;
+ color: rgba(var(--center-channel-color-rgb), 0.56);
+`;
+
+type BotDropdownProps = {
+ bots: LLMBot[]
+ activeBot: LLMBot | null
+ setActiveBot: (bot: LLMBot) => void
+ container: typeof DotMenuButton
+ children: JSX.Element
+}
+
+export const BotDropdown = (props: BotDropdownProps) => {
+ return (
+
+ {/*
+
+ */}
+ {props.bots.map((bot) => {
+ const botProfileURL = Client4.getProfilePictureUrl(bot.id, bot.lastIconUpdate);
+ return (
+ props.setActiveBot(bot)}
+ >
+
+ {bot.displayName}
+ {props.activeBot && (props.activeBot.id === bot.id) && (
+
+ )}
+
+ );
+ })}
+
+ );
+};
+
+const StyledDropdownMenu = styled(DropdownMenu)`
+ min-width: 270px;
+`;
+
+const StyledCheckIcon = styled(CheckIcon)`
+ margin-left: auto;
+ color: var(--button-bg);
+`;
+
+const StyledDropdownMenuItem = styled(DropdownMenuItem)`
+ padding: 8px 16px;
+`;
+
+const MenuInfoMessage = styled.div`
+ padding: 6px 20px;
+
+ color: rgba(var(--center-channel-color-rgb), 0.56);
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 16px;
+ letter-spacing: 0.48px;
+ text-transform: uppercase;
+`;
+
+const BotIconDropdownItem = styled.img`
+ border-radius: 50%;
+ width: 24px;
+ height: 24px;
+ margin-right: 8px;
+`;
+
+const SelectMessage = styled.div`
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 16px;
+ letter-spacing: 0.24px;
+ text-transform: uppercase;
+`;
diff --git a/webapp/channels/src/plugins/ai/components/dot_menu.tsx b/webapp/channels/src/plugins/ai/components/dot_menu.tsx
new file mode 100644
index 0000000000..22d7daeb20
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/dot_menu.tsx
@@ -0,0 +1,222 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {ComponentProps, useState} from 'react';
+import styled, {css} from 'styled-components';
+
+import {useUpdateEffect} from 'react-use';
+
+import Dropdown from './dropdown';
+import {PrimaryButton} from './assets/buttons';
+
+export const DotMenuButton = styled.div<{isActive: boolean}>`
+ display: inline-flex;
+ padding: 0;
+ border: none;
+ border-radius: 4px;
+ width: 28px;
+ height: 28px;
+ align-items: center;
+ justify-content: center;
+ fill: rgba(var(--center-channel-color-rgb), 0.56);
+ cursor: pointer;
+
+ color: ${(props) => (props.isActive ? 'var(--button-bg)' : 'rgba(var(--center-channel-color-rgb), 0.56)')};
+ background-color: ${(props) => (props.isActive ? 'rgba(var(--button-bg-rgb), 0.08)' : 'transparent')};
+
+ &:hover {
+ color: ${(props) => (props.isActive ? 'var(--button-bg)' : 'rgba(var(--center-channel-color-rgb), 0.56)')};
+ background-color: ${(props) => (props.isActive ? 'rgba(var(--button-bg-rgb), 0.08)' : 'rgba(var(--center-channel-color-rgb), 0.08)')};
+ }
+`;
+
+export const DropdownMenu = styled.div`
+ display: flex;
+ flex-direction: column;
+
+ width: max-content;
+ min-width: 16rem;
+ text-align: left;
+ list-style: none;
+
+ padding: 10px 0;
+ font-family: Open Sans;
+ font-style: normal;
+ font-weight: normal;
+ font-size: 14px;
+ line-height: 20px;
+ color: var(--center-channel-color);
+
+ background: var(--center-channel-bg);
+ border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
+ box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.12);
+ border-radius: 4px;
+
+ z-index: 12;
+`;
+
+type DotMenuProps = {
+ children: React.ReactNode;
+ icon: JSX.Element;
+ dotMenuButton?: typeof DotMenuButton | typeof PrimaryButton;
+ dropdownMenu?: typeof DropdownMenu;
+ title?: string;
+ disabled?: boolean;
+ className?: string;
+ isActive?: boolean;
+ onOpenChange?: (isOpen: boolean) => void;
+ closeOnClick?: boolean;
+};
+
+type DropdownProps = Omit
, 'target' | 'children' | 'isOpen'>;
+
+const DotMenu = ({
+ children,
+ icon,
+ title,
+ className,
+ disabled,
+ isActive,
+ closeOnClick = true,
+ dotMenuButton: MenuButton = DotMenuButton,
+ dropdownMenu: Menu = DropdownMenu,
+ onOpenChange,
+ ...props
+}: DotMenuProps & DropdownProps) => {
+ const [isOpen, setOpen] = useState(false);
+ const toggleOpen = () => {
+ setOpen(!isOpen);
+ };
+ useUpdateEffect(() => {
+ onOpenChange?.(isOpen);
+ }, [isOpen]);
+
+ const button = (
+
+ // @ts-ignore
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleOpen();
+ }}
+ onKeyDown={(e: KeyboardEvent) => {
+ // Handle Enter and Space as clicking on the button
+ if (e.key === 'Space' || e.key === 'Enter') {
+ e.stopPropagation();
+ toggleOpen();
+ }
+ }}
+ tabIndex={0}
+ className={className}
+ role={'button'}
+ disabled={disabled ?? false}
+ data-testid={'menuButton' + (title ?? '')}
+ >
+ {icon}
+
+ );
+
+ return (
+
+
+
+ );
+};
+
+export const DropdownMenuItemStyled = styled.a`
+ && {
+ font-family: "SuisseIntl", sans-serif;
+ font-style: normal;
+ font-weight: normal;
+ font-size: 14px;
+ color: var(--center-channel-color);
+ padding: 10px 20px;
+ text-decoration: unset;
+ display: inline-flex;
+ align-items: center;
+
+ >.icon {
+ margin-right: 8px;
+ }
+
+ &:hover {
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ color: var(--center-channel-color);
+ }
+ &&:focus {
+ text-decoration: none;
+ color: inherit;
+ }
+}
+`;
+
+export const DisabledDropdownMenuItemStyled = styled.div`
+ && {
+ cursor: default;
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: normal;
+ font-size: 14px;
+ color: var(--center-channel-color-40);
+ padding: 8px 20px;
+ text-decoration: unset;
+}
+`;
+
+export const iconSplitStyling = css`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+`;
+
+export const DropdownMenuItem = (props: { children: React.ReactNode, onClick?: () => void, className?: string}) => {
+ return (
+
+ {props.children}
+
+ );
+};
+
+// Alternate dot menu button. Use `dotMenuButton={TitleButton}` for this style.
+export const TitleButton = styled.div<{isActive: boolean}>`
+ padding: 2px 2px 2px 6px;
+ display: inline-flex;
+ border-radius: 4px;
+ color: ${({isActive}) => (isActive ? 'var(--button-bg)' : 'var(--center-channel-color)')};
+ background: ${({isActive}) => (isActive ? 'rgba(var(--button-bg-rgb), 0.08)' : 'auto')};
+
+ min-width: 0;
+
+ &:hover {
+ background: ${({isActive}) => (isActive ? 'rgba(var(--button-bg-rgb), 0.08)' : 'rgba(var(--center-channel-color-rgb), 0.08)')};
+ }
+`;
+
+export default DotMenu;
diff --git a/webapp/channels/src/plugins/ai/components/dropdown.tsx b/webapp/channels/src/plugins/ai/components/dropdown.tsx
new file mode 100644
index 0000000000..6417c62a37
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/dropdown.tsx
@@ -0,0 +1,100 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {ComponentProps, cloneElement, useState} from 'react';
+import styled from 'styled-components';
+
+import {
+ FloatingFocusManager,
+ FloatingPortal,
+ Placement,
+ autoUpdate,
+ flip,
+ offset,
+ shift,
+ useDismiss,
+ useFloating,
+ useInteractions,
+ useRole,
+} from '@floating-ui/react-dom-interactions';
+
+const FloatingContainer = styled.div`
+ min-width: 16rem;
+ z-index: 50;
+`;
+
+type DropdownProps = {
+ target: JSX.Element;
+ children: React.ReactNode;
+ placement?: Placement;
+ offset?: Parameters[0];
+ flip?: Parameters[0];
+ shift?: Parameters[0];
+ focusManager?: boolean | Omit, 'context' | 'children'>;
+ portal?: boolean;
+ isOpen: boolean;
+ onOpenChange?: ((open: boolean) => void);
+};
+
+const Dropdown = (props: DropdownProps) => {
+ const [isOpen, setIsOpen] = useState(props.isOpen);
+
+ const open = props.isOpen ?? isOpen;
+
+ const setOpen = (updatedOpen: boolean) => {
+ props.onOpenChange?.(updatedOpen);
+ setIsOpen(updatedOpen);
+ };
+
+ const {strategy, x, y, reference, floating, context} = useFloating({
+ open,
+ onOpenChange: setOpen,
+ placement: props.placement ?? 'bottom-start',
+ middleware: [offset(props.offset ?? 2), flip(props.flip), shift(props.shift ?? {padding: 2})],
+ whileElementsMounted: autoUpdate,
+ });
+
+ const {getReferenceProps, getFloatingProps} = useInteractions([
+ useRole(context),
+ useDismiss(context),
+ ]);
+
+ const MaybePortal = (props.portal ?? true) ? FloatingPortal : React.Fragment; // 🤷
+
+ let content = (
+
+ {props.children}
+
+ );
+
+ if (props.focusManager ?? true) {
+ content = (
+
+ {content}
+
+ );
+ }
+
+ return (
+ <>
+ {cloneElement(props.target, getReferenceProps({ref: reference, ...props.target.props}))}
+
+ {open && content}
+
+ >
+ );
+};
+
+export default Dropdown;
diff --git a/webapp/channels/src/plugins/ai/components/dropdown_info.tsx b/webapp/channels/src/plugins/ai/components/dropdown_info.tsx
new file mode 100644
index 0000000000..6f0f488441
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/dropdown_info.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+import styled from 'styled-components';
+import {LightbulbOutlineIcon} from '@infomaniak/compass-icons/components';
+
+const DropdownMenuItemInfo = styled.div`
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+
+ font-size: 12px;
+ font-weight: 400;
+ line-height: 16px;
+ color: rgba(var(--center-channel-color-rgb), 0.72);
+
+ max-width: 240px;
+ padding: 8px 16px;
+`;
+
+const LightbulbOutlineIconStyled = styled(LightbulbOutlineIcon)`
+ min-width: 22px;
+ min-height: 22px;
+
+ padding: 4px;
+
+ color: rgba(var(--center-channel-color-rgb), 0.56);
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ border-radius: 16px;
+`;
+
+export const Divider = styled.div`
+ border: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
+ margin-top: 8px;
+ margin-bottom: 8px;
+`;
+
+export const DropdownInfoOnlyVisibleToYou = () => {
+ return (
+
+
+ {/* */}
+
+ );
+};
diff --git a/webapp/channels/src/plugins/ai/components/llmbot_post.tsx b/webapp/channels/src/plugins/ai/components/llmbot_post.tsx
new file mode 100644
index 0000000000..1c012f4bd9
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/llmbot_post.tsx
@@ -0,0 +1,338 @@
+import type {MouseEvent} from 'react';
+import React, {useEffect, useRef, useState} from 'react';
+import {FormattedMessage} from 'react-intl';
+import {useSelector} from 'react-redux';
+import styled, {css, createGlobalStyle} from 'styled-components';
+
+import {SendIcon} from '@mattermost/compass-icons/components';
+import type {Post} from '@mattermost/types/posts';
+import type {GlobalState} from '@mattermost/types/store';
+
+// import {doPostbackSummary, doRegenerate, doStopGenerating} from '@/client';
+
+// import IconCancel from './assets/icon_cancel';
+// import IconRegenerate from './assets/icon_regenerate';
+import {handleEvent} from 'actions/websocket_actions';
+
+// import {useSelectNotAIPost, useSelectPost} from '@/hooks';
+
+import WebSocketClient from 'client/web_websocket_client';
+
+import IconRegenerate from './assets/icon-regenerate';
+import IconCancel from './assets/icon_cancel';
+import PostText from './post_text';
+
+const PostMessagePreview = (window as any).Components.PostMessagePreview;
+
+const FixPostHover = createGlobalStyle<{disableHover?: string}>`
+ ${(props) => props.disableHover && css`
+ &&&& {
+ [data-testid="post-menu-${props.disableHover}"] {
+ display: none !important;
+ }
+ [data-testid="post-menu-${props.disableHover}"]:hover {
+ display: none !important;
+ }
+ }`}
+`;
+
+const PostBody = styled.div<{disableHover?: boolean}>`
+ ${(props) => props.disableHover && css`
+ ::before {
+ content: '';
+ position: absolute;
+ width: 110%;
+ height: 110%;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ }`}
+`;
+
+// const ControlsBar = styled.div`
+// display: flex;
+// flex-direction: row;
+// justify-content: left;
+// height: 28px;
+// margin-top: 8px;
+// gap: 4px;
+// `;
+
+// const GenerationButton = styled.button`
+// display: flex;
+// border: none;
+// height: 24px;
+// padding: 4px 10px;
+// align-items: center;
+// justify-content: center;
+// gap: 6px;
+// border-radius: 4px;
+// background: rgba(var(--center-channel-color-rgb), 0.08);
+// color: rgba(var(--center-channel-color-rgb), 0.64);
+
+// font-size: 12px;
+// line-height: 16px;
+// font-weight: 600;
+
+// :hover {
+// background: rgba(var(--center-channel-color-rgb), 0.12);
+// color: rgba(var(--center-channel-color-rgb), 0.72);
+// }
+
+// :active {
+// background: rgba(var(--button-bg-rgb), 0.08);
+// }
+// `;
+
+// const PostSummaryButton = styled(GenerationButton)`
+// background: var(--button-bg);
+// color: var(--button-color);
+
+// :hover {
+// background: rgba(var(--button-bg-rgb), 0.88);
+// color: var(--button-color);
+// }
+
+// :active {
+// background: rgba(var(--button-bg-rgb), 0.92);
+// }
+// `;
+
+// const StopGeneratingButton = styled.button`
+// display: flex;
+// padding: 5px 12px;
+// align-items: center;
+// justify-content: center;
+// gap: 6px;
+// border-radius: 4px;
+// border: 1px solid rgba(var(--center-channel-color,0.12));
+// background: var(--center-channel-bg);
+
+// box-shadow: 0px 4px 6px 0px rgba(0, 0, 0, 0.12);
+
+// position: absolute;
+// left: 50%;
+// top: -5px;
+// transform: translateX(-50%);
+
+// color: var(--button-bg);
+
+// font-size: 12px;
+// font-weight: 600;
+// `;
+
+// const PostSummaryHelpMessage = styled.div`
+// font-size: 14px;
+// font-style: italic;
+// font-weight: 400;
+// line-height: 20px;
+// border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
+
+// padding-top: 8px;
+// padding-bottom: 8px;
+// margin-top: 16px;
+// `;
+
+type PostUpdateWebsocketMessage = {
+ channel_id: string;
+ post_id: string;
+};
+
+type PostUpdateWebsocketMessageControl = PostUpdateWebsocketMessage & {control: 'start' | 'end'};
+type PostUpdateWebsocketMessageNext = PostUpdateWebsocketMessage & {next: string};
+type PostUpdateWebsocketMessageAny = PostUpdateWebsocketMessageControl | PostUpdateWebsocketMessageNext;
+type PostUpdateWebsocket = {
+ event: string;
+ data: PostUpdateWebsocketMessageAny;
+};
+
+const isPostUpdateWebsocketMessageNext = (msg: any): msg is PostUpdateWebsocketMessageNext =>
+ typeof msg.next === 'string';
+
+interface Props {
+ post: Post;
+
+ // websocketRegister: (postID: string, handler: (msg: WebSocketMessage) => void) => void;
+ // websocketUnregister: (postID: string) => void;
+}
+
+export const LLMBotPost = (props: Props) => {
+ // const selectPost = useSelectNotAIPost();
+ const [message, setMessage] = useState(props.post.message as string);
+
+ // Generating is true while we are reciving new content from the websocket
+ const [generating, setGenerating] = useState(false);
+
+ // Stopped is a flag that is used to prevent the websocket from updating the message after the user has stopped the generation
+ // Needs a ref because of the useEffect closure.
+ const [stopped, setStopped] = useState(false);
+ const stoppedRef = useRef(stopped);
+ stoppedRef.current = stopped;
+
+ // const currentUserId = useSelector((state) => state.entities.users.currentUserId);
+ // const rootPost = useSelector((state) => state.entities.posts.posts[props.post.root_id]);
+ useEffect(() => {
+ function handleCustomMattermostEvent({event, data}: PostUpdateWebsocket) {
+ if (event === 'custom_mattermost-ai_postupdate' && data.post_id === props.post.id) {
+ if (isPostUpdateWebsocketMessageNext(data)) {
+ if (stoppedRef.current) {
+ return;
+ }
+ setGenerating(true);
+ setMessage(data.next);
+ } else if (data.control === 'end') {
+ setGenerating(false);
+ setStopped(false);
+ } else if (data.control === 'start') {
+ setGenerating(true);
+ setStopped(false);
+ }
+ }
+ }
+ WebSocketClient.addMessageListener(handleCustomMattermostEvent);
+
+ return () => {
+ WebSocketClient.removeMessageListener(handleCustomMattermostEvent);
+ };
+ }, []);
+
+ // const regnerate = () => {
+ // setGenerating(true);
+ // setStopped(false);
+ // setMessage('');
+ // doRegenerate(props.post.id);
+ // };
+
+ // const stopGenerating = () => {
+ // setStopped(true);
+ // setGenerating(false);
+ // doStopGenerating(props.post.id);
+ // };
+
+ const stopPropagationIfGenerating = (e: MouseEvent) => {
+ if (generating) {
+ e.stopPropagation();
+ }
+ };
+
+ // const postSummary = async () => {
+ // const result = await doPostbackSummary(props.post.id);
+ // selectPost(result.rootid, result.channelid);
+ // };
+
+ // const requesterIsCurrentUser = (props.post.props?.llm_requester_user_id === currentUserId);
+ // const isThreadSummaryPost = (props.post.props?.referenced_thread && props.post.props?.referenced_thread !== '');
+ // const isNoShowRegen = (props.post.props?.no_regen && props.post.props?.no_regen !== '');
+ // const isTranscriptionResult = rootPost?.props?.referenced_transcript_post_id && rootPost?.props?.referenced_transcript_post_id !== '';
+
+ let permalinkView = null;
+ if (PostMessagePreview) { // Ignore permalink if version does not exporrt PostMessagePreview
+ const permalinkData = extractPermalinkData(props.post);
+ if (permalinkData !== null) {
+ permalinkView = (
+
+ );
+ }
+ }
+
+ // const showRegenerate = !generating && requesterIsCurrentUser && !isNoShowRegen;
+ const showPostbackButton = !generating; //&& requesterIsCurrentUser && isTranscriptionResult;
+ // const showControlsBar = (showRegenerate || showPostbackButton) && message !== '';
+
+ return (
+
+
+ {/* { isThreadSummaryPost && permalinkView && */}
+ { permalinkView &&
+ <>
+ {permalinkView}
+ >
+ }
+
+
+ {/* { generating && requesterIsCurrentUser &&
+
+
+
+
+ } */}
+ {/* { showPostbackButton &&
+
+
+
+ } */}
+ {/* { showControlsBar &&
+
+ {showPostbackButton &&
+
+
+
+
+ }
+ { showRegenerate &&
+
+
+
+
+ }
+
+ } */}
+
+ );
+};
+
+type PermalinkData = {
+ channel_display_name: string;
+ channel_id: string;
+ post_id: string;
+ team_name: string;
+ post: {
+ message: string;
+ user_id: string;
+ };
+}
+
+function extractPermalinkData(post: any): PermalinkData | null {
+ for (const embed of post?.metadata?.embeds || []) {
+ if (embed.type === 'permalink') {
+ return embed.data;
+ }
+ }
+ return null;
+}
+
diff --git a/webapp/channels/src/plugins/ai/components/pill.tsx b/webapp/channels/src/plugins/ai/components/pill.tsx
new file mode 100644
index 0000000000..738c5ee8c6
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/pill.tsx
@@ -0,0 +1,23 @@
+import styled from 'styled-components';
+
+export const Pill = styled.div`
+ background: rgb(var(--semantic-color-info));
+ color: white;
+ border-radius: 4px;
+ font-size: 10px;
+ font-weight: 600;
+ line-height: 16px;
+ padding: 0 4px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+`;
+
+export const DangerPill = styled(Pill)`
+ background: rgb(var(--semantic-color-danger));
+`;
+
+export const GrayPill = styled(Pill)`
+ color: var(--center-channel-color);
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+`;
diff --git a/webapp/channels/src/plugins/ai/components/post_menu.tsx b/webapp/channels/src/plugins/ai/components/post_menu.tsx
new file mode 100644
index 0000000000..abcdc5a01d
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/post_menu.tsx
@@ -0,0 +1,108 @@
+import React from 'react';
+import {FormattedMessage, useIntl} from 'react-intl';
+import {useDispatch, useSelector} from 'react-redux';
+import styled from 'styled-components';
+
+import type {Post} from '@mattermost/types/posts';
+
+import {Client4} from 'mattermost-redux/client';
+import {getUser} from 'mattermost-redux/selectors/entities/users';
+
+import {selectPostById} from 'actions/views/rhs';
+import {handleEvent} from 'actions/websocket_actions';
+
+import type {GlobalState} from 'types/store';
+
+import IconAI from './assets/icon_ai';
+import IconThreadSummarization from './assets/icon_thread_summarization';
+import {DropdownBotSelector} from './bot_slector';
+import DotMenu, {DropdownMenu, DropdownMenuItem} from './dot_menu';
+import {Divider, DropdownInfoOnlyVisibleToYou} from './dropdown_info';
+import {GrayPill} from './pill';
+
+// import IconReactForMe from './assets/icon_react_for_me';
+// import {DropdownBotSelector} from './bot_slector';
+// import {useSelector} from 'react-redux';
+// import {getBotAccounts} from 'mattermost-redux/selectors/entities/bots';
+// import {getPost} from 'mattermost-redux/actions/posts';
+
+// const BotPill = styled(GrayPill)`
+// font-size: 12px;
+// padding: 2px 6px;
+// gap: 0;
+// `;
+
+type Props = {
+ post: Post;
+ location: string;
+}
+
+const PostMenu = (props: Props) => {
+ const dispatch = useDispatch();
+ const intl = useIntl();
+
+ const post = props.post;
+
+ const user = useSelector((state: GlobalState) => getUser(state, post.user_id));
+ const isBot = Boolean(user && user.is_bot);
+
+ // const {bots, activeBot, setActiveBot} = useBotlist();
+
+ // const isBasicsLicensed = useIsBasicsLicensed();
+
+ const summarizePost = async (postId: string) => {
+ try {
+ const result = await Client4.doSummarize(postId, 'kchat.bot');
+ dispatch(selectPostById(result.postid));
+ Client4.viewMyChannel(result.channelid);
+ } catch (error) {
+ console.error('Error summarizing post:', error);
+ }
+ };
+
+ // if (!isBasicsLicensed) {
+ // return null;
+ // }
+
+ // Unconfigured state
+ // if (bots && botsArray.length === 0) {
+ // return null;
+ // }
+
+ if (isBot || props.location === 'RHS_ROOT') {
+ return null;
+ }
+
+ return (
+ }
+ title={intl.formatMessage({id: 'ai.actions', defaultMessage: 'AI Actions'})}
+ dropdownMenu={StyledDropdownMenu}
+ >
+ {/*
+ */}
+ summarizePost(post.id)}>
+
+
+
+
+
+
+ {/*
+ */}
+
+ );
+};
+
+const StyledDropdownMenu = styled(DropdownMenu)`
+ min-width: 240px;
+`;
+
+export default PostMenu;
diff --git a/webapp/channels/src/plugins/ai/components/post_text.tsx b/webapp/channels/src/plugins/ai/components/post_text.tsx
new file mode 100644
index 0000000000..bb20b44dd7
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/post_text.tsx
@@ -0,0 +1,106 @@
+import React, {useEffect} from 'react';
+import {useSelector} from 'react-redux';
+import styled, {keyframes, css} from 'styled-components';
+
+import type {Channel} from '@mattermost/types/channels';
+import type {GlobalState} from '@mattermost/types/store';
+import type {Team} from '@mattermost/types/teams';
+
+import Markdown from 'components/markdown';
+
+export type ChannelNamesMap = {
+ [name: string]: {
+ display_name: string;
+ team_name?: string;
+ } | Channel;
+};
+
+interface Props {
+ message: string;
+ channelID: string;
+ postID: string;
+ showCursor?: boolean;
+}
+
+const blinkKeyframes = keyframes`
+ 0% { opacity: 0.48; }
+ 20% { opacity: 0.48; }
+ 100% { opacity: 0.12; }
+`;
+
+const TextContainer = styled.div<{showCursor?: boolean}>`
+ ${(props) => props.showCursor && css`
+ >ul:last-child>li:last-child>span:not(:has(li))::after,
+ >ol:last-child>li:last-child>span:not(:has(li))::after,
+ >ul:last-child>li:last-child>span>ul>li:last-child>span:not(:has(li))::after,
+ >ol:last-child>li:last-child>span>ul>li:last-child>span:not(:has(li))::after,
+ >ul:last-child>li:last-child>span>ol>li:last-child>span:not(:has(li))::after,
+ >ol:last-child>li:last-child>span>ol>li:last-child>span:not(:has(li))::after,
+ >h1:last-child::after,
+ >h2:last-child::after,
+ >h3:last-child::after,
+ >h4:last-child::after,
+ >h5:last-child::after,
+ >h6:last-child::after,
+ >blockquote:last-child>p::after,
+ >p:last-child::after {
+ content: '';
+ width: 7px;
+ height: 16px;
+ background: rgba(var(--center-channel-color-rgb), 0.48);
+ display: inline-block;
+ margin-left: 3px;
+
+ animation: ${blinkKeyframes} 500ms ease-in-out infinite;
+ }
+ `}
+`;
+
+const PostText = (props: Props) => {
+ const channel = useSelector((state) => state.entities.channels.channels[props.channelID]);
+ const team = useSelector((state) => state.entities.teams.teams[channel?.team_id]);
+ const siteURL = useSelector((state) => state.entities.general.config.SiteURL);
+
+ // @ts-ignore
+ const {formatText, messageHtmlToComponent} = window.PostUtils;
+
+ const markdownOptions = {
+ singleline: false,
+ mentionHighlight: true,
+ atMentions: true,
+ team,
+ unsafeLinks: true,
+ minimumHashtagLength: 1000000000,
+ siteURL,
+ };
+
+ const messageHtmlToComponentOptions = {
+ hasPluginTooltips: true,
+ latex: false,
+ inlinelatex: false,
+ postId: props.postID,
+ };
+
+ const text = messageHtmlToComponent(
+ formatText(props.message, markdownOptions),
+ messageHtmlToComponentOptions,
+ );
+
+ if (!text) {
+ return {};
+ }
+
+ return (
+
+
+ {/* {text} */}
+
+ );
+};
+
+export default PostText;
diff --git a/webapp/channels/src/plugins/ai/components/rhs/common.tsx b/webapp/channels/src/plugins/ai/components/rhs/common.tsx
new file mode 100644
index 0000000000..9e723f8fda
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/rhs/common.tsx
@@ -0,0 +1,52 @@
+import styled from 'styled-components';
+
+export const Button = styled.button`
+ border-radius: 4px;
+ padding: 8px 16px;
+ display: flex;
+ align-items: center;
+ font-weight: 600;
+ font-size: 12px;
+ background-color: rgb(var(--center-channel-bg-rgb));
+ border: 0;
+
+ &:hover {
+ background-color: rgba(var(--button-bg-rgb), 0.08);
+ color: rgb(var(--link-color-rgb));
+ svg {
+ fill: rgb(var(--link-color-rgb))
+ }
+ }
+
+ svg {
+ fill: rgb(var(--center-channel-color));
+ margin-right: 6px;
+ }
+
+ i {
+ display: flex;
+ font-size: 14px;
+ margin-right: 2px;
+ }
+`;
+
+export const RHSTitle = styled.div`
+ font-family: Metropolis;
+ font-weight: 600;
+ font-size: 22px;
+ line-height: 28px;
+`;
+
+export const RHSText = styled.div`
+ font-weight: 500;
+ font-size: 14px;
+ line-height: 20px;
+`;
+
+export const RHSPaddingContainer = styled.div`
+ margin: 0 24px;
+ margin-top: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`;
diff --git a/webapp/channels/src/plugins/ai/components/rhs/rhs.tsx b/webapp/channels/src/plugins/ai/components/rhs/rhs.tsx
new file mode 100644
index 0000000000..7747633e81
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/rhs/rhs.tsx
@@ -0,0 +1,179 @@
+import React, {useState, useEffect, useCallback} from 'react';
+import {FormattedMessage, useIntl} from 'react-intl';
+import {useDispatch, useSelector} from 'react-redux';
+import styled from 'styled-components';
+
+import {GlobalState} from '@mattermost/types/store';
+
+import manifest from '@/manifest';
+
+import {getAIThreads, updateRead} from '@/client';
+
+import {useBotlist} from '@/bots';
+
+import RHSImage from '../assets/rhs_image';
+
+import ThreadItem from './thread_item';
+import RHSHeader from './rhs_header';
+import RHSNewTab from './rhs_new_tab';
+import {RHSPaddingContainer, RHSText, RHSTitle} from './common';
+
+const ThreadViewer = (window as any).Components.ThreadViewer && styled((window as any).Components.ThreadViewer)`
+ height: 100%;
+`;
+
+const ThreadsList = styled.div`
+ overflow-y: scroll;
+`;
+
+const RhsContainer = styled.div`
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+`;
+
+const RHSDivider = styled.div`
+ border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
+ margin-top: 12px;
+ margin-bottom: 12px;
+`;
+
+const RHSSubtitle = styled(RHSText)`
+ font-weight: 600;
+`;
+
+const RHSBullet = styled.li`
+ margin-bottom: 8px;
+`;
+
+export interface AIThread {
+ ID: string;
+ Message: string;
+ ChannelID: string;
+ Title: string;
+ ReplyCount: number;
+ UpdateAt: number;
+}
+
+const twentyFourHoursInMS = 24 * 60 * 60 * 1000;
+
+export default function RHS() {
+ const dispatch = useDispatch();
+ const intl = useIntl();
+ const [currentTab, setCurrentTab] = useState('new');
+ const selectedPostId = useSelector((state: any) => state['plugins-' + manifest.id].selectedPostId);
+ const currentUserId = useSelector((state) => state.entities.users.currentUserId);
+ const currentTeamId = useSelector((state) => state.entities.teams.currentTeamId);
+
+ const [threads, setThreads] = useState(null);
+
+ useEffect(() => {
+ const fetchThreads = async () => {
+ setThreads(await getAIThreads());
+ };
+ if (currentTab === 'threads') {
+ fetchThreads();
+ } else if (currentTab === 'thread' && Boolean(selectedPostId)) {
+ // Update read for the thread to tommorow. We don't really want the unreads thing to show up.
+ updateRead(currentUserId, currentTeamId, selectedPostId, Date.now() + twentyFourHoursInMS);
+ }
+ return () => {
+ // Somtimes we are too fast for the server, so try again on unmount/switch.
+ if (selectedPostId) {
+ updateRead(currentUserId, currentTeamId, selectedPostId, Date.now() + twentyFourHoursInMS);
+ }
+ };
+ }, [currentTab, selectedPostId]);
+
+ const selectPost = useCallback((postId: string) => {
+ dispatch({type: 'SELECT_AI_POST', postId});
+ }, [dispatch]);
+
+ const {bots, activeBot, setActiveBot} = useBotlist();
+
+ // Unconfigured state
+ if (bots && bots.length === 0) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ let content = null;
+ if (selectedPostId) {
+ if (currentTab !== 'thread') {
+ setCurrentTab('thread');
+ }
+ content = (
+
+ );
+ } else if (currentTab === 'threads') {
+ if (threads && bots) {
+ content = (
+
+ {threads.map((p) => (
+ bot.dmChannelID === p.ChannelID)?.displayName ?? ''}
+ onClick={() => {
+ setCurrentTab('thread');
+ selectPost(p.ID);
+ }}
+ />))}
+
+ );
+ } else {
+ content = null;
+ }
+ } else if (currentTab === 'new') {
+ content = (
+
+ );
+ }
+ return (
+
+
+ {content}
+
+ );
+}
diff --git a/webapp/channels/src/plugins/ai/components/rhs/rhs_header.tsx b/webapp/channels/src/plugins/ai/components/rhs/rhs_header.tsx
new file mode 100644
index 0000000000..4da9613e99
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/rhs/rhs_header.tsx
@@ -0,0 +1,163 @@
+
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+import {useDispatch, useSelector} from 'react-redux';
+import {Link, useLocation, useRouteMatch} from 'react-router-dom';
+import styled from 'styled-components';
+
+import {ChevronDownIcon} from '@mattermost/compass-icons/components';
+
+import {getChannelByName} from 'mattermost-redux/selectors/entities/channels';
+
+import {switchToChannel} from 'actions/views/channel';
+import {closeRightHandSide} from 'actions/views/rhs';
+
+import type {GlobalState} from 'types/store';
+
+import {Button} from './common';
+
+import {BotDropdown} from '../bot_slector';
+import {DotMenuButton} from '../dot_menu';
+
+import type {LLMBot} from '@/bots';
+
+type Props = {
+ currentTab?: string;
+ bots?: LLMBot[] | null;
+ activeBot?: LLMBot | null;
+ setCurrentTab?: (tab: string) => void;
+ selectPost?: (postId: string) => void;
+ setActiveBot?: (bot: LLMBot) => void;
+ channelName: string;
+ onChatHistoryClick?: () => void;
+}
+
+const RHSHeader = (props: Props) => {
+ const dispatch = useDispatch();
+ const kChatBotChannel = useSelector((state: GlobalState) => getChannelByName(state, props.channelName));
+
+ let historyButton = null;
+ if (props.currentTab === 'threads') {
+ historyButton = (
+
+
+
+
+ );
+ } else {
+ historyButton = (
+
+
+
+
+ );
+ }
+ const currentBotName = props.activeBot?.displayName ?? '';
+ return (
+
+ {historyButton}
+ {props.currentTab !== 'new' && kChatBotChannel && (
+ {
+ dispatch(closeRightHandSide());
+ dispatch(switchToChannel(kChatBotChannel));
+ }}
+
+ // onClick={() => {
+ // props.setCurrentTab('new');
+ // props.selectPost('');
+ // }}
+ >
+
+
+
+ )}
+ {(props.currentTab === 'new' && props.bots) && (
+
+ <>
+ {currentBotName}
+
+ >
+
+ )}
+
+ );
+};
+
+const HistoryButton = styled(Button)`
+ padding: 8px 12px;
+ color: rgba(var(--center-channel-color-rgb), 0.64);
+`;
+
+const ButtonDisabled = styled(Button)`
+ &:hover {
+ background: transparent;
+ color: rgb(var(--center-channel-color));
+ cursor: unset;
+ }
+`;
+
+const NewChatButton = styled(Button)`
+ color: rgb(var(--link-color-rgb));
+ &:hover {
+ color: rgb(var(--link-color-rgb));
+ background-color: rgba(var(--button-bg-rgb), 0.08);
+ }
+
+ &:active {
+ background-color: rgba(var(--button-bg-rgb), 0.12);
+ }
+`;
+
+const Header = styled.div`
+ display: flex;
+ padding 8px 12px;
+ justify-content: space-between;
+ align-items: center;
+ border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
+ flex-wrap: wrap;
+`;
+
+const SelectorDropdown = styled(DotMenuButton)<{isActive: boolean}>`
+ display: flex;
+ align-items: center;
+ padding: 2px 4px 2px 6px;
+ border-radius: 4px;
+ height: 20px;
+ width: auto;
+ max-width: 145px;
+ overflow: ellipsis;
+
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 16px;
+
+ color: ${(props) => (props.isActive ? 'var(--button-bg)' : 'var(--center-channel-color-rgb)')};
+ background-color: ${(props) => (props.isActive ? 'rgba(var(--button-bg-rgb), 0.16)' : 'rgba(var(--center-channel-color-rgb), 0.08)')};
+
+ &:hover {
+ color: ${(props) => (props.isActive ? 'var(--button-bg)' : 'var(--center-channel-color-rgb)')};
+ background-color: ${(props) => (props.isActive ? 'rgba(var(--button-bg-rgb), 0.16)' : 'rgba(var(--center-channel-color-rgb), 0.16)')};
+ }
+`;
+
+export default React.memo(RHSHeader);
diff --git a/webapp/channels/src/plugins/ai/components/rhs/thread_item.tsx b/webapp/channels/src/plugins/ai/components/rhs/thread_item.tsx
new file mode 100644
index 0000000000..8ae59b0055
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/rhs/thread_item.tsx
@@ -0,0 +1,101 @@
+import React from 'react';
+import styled from 'styled-components';
+
+import {GrayPill} from '../pill';
+
+const ThreadItemContainer = styled.div`
+ padding: 16px;
+ cursor: pointer;
+ border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12)
+`;
+
+const Timestamp = (window as any).Components.Timestamp;
+
+const Title = styled.div`
+ color: var(--center-channel-color);
+ display: flex;
+ align-items: center;
+ margin-bottom: 4px;
+ justify-content: space-between;
+`;
+
+const TitleText = styled.div`
+ font-size: 14px;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+`;
+
+const Preview = styled.div`
+ overflow: hidden;
+ color: var(--center-channel-color);
+ text-overflow: ellipsis;
+ whitespace: nowrap;
+ margin-bottom: 12px;
+ height: 40px;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+`;
+
+const RepliesCount = styled.div`
+ color: rgba(var(--center-channel-color-rgb), 0.64);
+ font-weight: 600;
+`;
+
+const LastActivityDate = styled.div`
+ color: rgba(var(--center-channel-color-rgb), 0.64);
+ font-size: 12px;
+ font-weight: 400;
+ white-space: nowrap;
+ margin-left: 13px;
+`;
+
+const Label = styled(GrayPill)`
+ padding: 0 4px;
+ font-size: 10px;
+ font-weight: 600;
+ line-height: 16px;
+`;
+
+const Footer = styled.div`
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+`;
+
+type Props = {
+ postTitle: string;
+ postMessage: string;
+ repliesCount: number;
+ lastActivityDate: number;
+ label: string;
+ onClick: () => void;
+}
+
+const DefaultTitle = 'Conversation with Copilot';
+
+export default function ThreadItem(props: Props) {
+ const repliesText = props.repliesCount === 1 ? '1 reply' : `${props.repliesCount} replies`;
+ return (
+
+
+ {props.postTitle || DefaultTitle}
+
+
+
+
+ {props.postMessage}
+
+
+ );
+}
diff --git a/webapp/channels/src/plugins/ai/components/svg.tsx b/webapp/channels/src/plugins/ai/components/svg.tsx
new file mode 100644
index 0000000000..3f4c97c489
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/svg.tsx
@@ -0,0 +1,10 @@
+import styled from 'styled-components';
+
+// Hat-tip: https://www.pinkdroids.com/blog/svg-react-styled-components/
+const Svg = styled.svg.attrs({
+ version: '1.1',
+ xmlns: 'http://www.w3.org/2000/svg',
+ xmlnsXlink: 'http://www.w3.org/1999/xlink',
+})``;
+
+export default Svg;
diff --git a/webapp/channels/src/plugins/ai/components/thread_summarize.tsx b/webapp/channels/src/plugins/ai/components/thread_summarize.tsx
new file mode 100644
index 0000000000..c36c2f7b66
--- /dev/null
+++ b/webapp/channels/src/plugins/ai/components/thread_summarize.tsx
@@ -0,0 +1,14 @@
+import React from 'react'
+import {FormattedMessage} from 'react-intl';
+import IconThreadSummarization from './assets/icon_thread_summarization';
+
+const ThreadSummarizeMenuItem = (
+ <>
+
+
+
+
+ >
+);
+
+export default ThreadSummarizeMenuItem;
diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts
index 1bbcede87c..e8854eda08 100644
--- a/webapp/platform/client/src/client4.ts
+++ b/webapp/platform/client/src/client4.ts
@@ -53,7 +53,7 @@ import {
ServerChannel,
PendingGuests,
} from '@mattermost/types/channels';
-import {Options, StatusOK, ClientResponse, LogLevel, FetchPaginatedThreadOptions} from '@mattermost/types/client4';
+import {Options, StatusOK, ClientResponse, LogLevel, FetchPaginatedThreadOptions, SummarizeResult} from '@mattermost/types/client4';
import {Compliance} from '@mattermost/types/compliance';
import {
ClientConfig,
@@ -4865,6 +4865,33 @@ export default class Client4 {
})}
)
}
+
+ //
+ // PLUGIN AI
+ //
+ async doSummarize(postId: string, botUsername: string): Promise {
+ const url = `${this.getPostRoute(postId)}/summarize?botUsername=${botUsername}`;
+
+ return this.doFetch(url, {method: 'post'});
+ }
+
+ async doStopGenerating(postId: string) {
+ const url = `${this.getPostRoute(postId)}/stop`;
+
+ return this.doFetch(url, {method: 'post'});
+ }
+
+ async doRegenerate(postId: string) {
+ const url = `${this.getPostRoute(postId)}/regenerate`;
+
+ return this.doFetch(url, {method: 'post'});
+ }
+
+ async doPostbackSummary(postId: string) {
+ const url = `${this.getPostRoute(postId)}/postback_summary`;
+
+ return this.doFetch(url, {method: 'post'});
+ }
}
export function parseAndMergeNestedHeaders(originalHeaders: any) {
diff --git a/webapp/platform/types/src/client4.ts b/webapp/platform/types/src/client4.ts
index cbe7587a77..4ebf8e81e0 100644
--- a/webapp/platform/types/src/client4.ts
+++ b/webapp/platform/types/src/client4.ts
@@ -36,3 +36,8 @@ export type FetchPaginatedThreadOptions = {
fromCreateAt?: number;
fromPost?: string;
}
+
+export type SummarizeResult ={
+ postid: string;
+ channelid: string;
+}
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index b556f7e643..b52eb34c99 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2638,6 +2638,25 @@ __metadata:
languageName: node
linkType: hard
+"@floating-ui/core@npm:^1.6.0":
+ version: 1.6.4
+ resolution: "@floating-ui/core@npm:1.6.4"
+ dependencies:
+ "@floating-ui/utils": "npm:^0.2.4"
+ checksum: 10/589430cbff4bac90b9b891e2c94c57dc113d39ac163552f547d9e4c7d21f09997b9d33e82ec717759caee678c47f845f14a3f28df6f029fcfcf3ad803ba4eb7c
+ languageName: node
+ linkType: hard
+
+"@floating-ui/dom@npm:^1.2.1":
+ version: 1.6.7
+ resolution: "@floating-ui/dom@npm:1.6.7"
+ dependencies:
+ "@floating-ui/core": "npm:^1.6.0"
+ "@floating-ui/utils": "npm:^0.2.4"
+ checksum: 10/a6a42bfd243c311f6040043808a6549c1db45fa36138b81cb1e615170d61fd2daf4f37accc1df3e0189405d97e3d71b12de39879c9d58ccf181c982b69cf6cf9
+ languageName: node
+ linkType: hard
+
"@floating-ui/dom@npm:^1.6.1":
version: 1.6.3
resolution: "@floating-ui/dom@npm:1.6.3"
@@ -2648,6 +2667,32 @@ __metadata:
languageName: node
linkType: hard
+"@floating-ui/react-dom-interactions@npm:0.13.3":
+ version: 0.13.3
+ resolution: "@floating-ui/react-dom-interactions@npm:0.13.3"
+ dependencies:
+ "@floating-ui/react-dom": "npm:^1.0.1"
+ aria-hidden: "npm:^1.1.3"
+ tabbable: "npm:^6.0.1"
+ peerDependencies:
+ react: ">=16.8.0"
+ react-dom: ">=16.8.0"
+ checksum: 10/839e682bfd2ea694729ced70011526150b84677f3ba6b2c13b60ea0068095e7326dff6b3f9fc51871fe0ff5b2ce12ad4eef62b30c5e688c9e3e5ba00a86aa8aa
+ languageName: node
+ linkType: hard
+
+"@floating-ui/react-dom@npm:^1.0.1":
+ version: 1.3.0
+ resolution: "@floating-ui/react-dom@npm:1.3.0"
+ dependencies:
+ "@floating-ui/dom": "npm:^1.2.1"
+ peerDependencies:
+ react: ">=16.8.0"
+ react-dom: ">=16.8.0"
+ checksum: 10/0f9ad9513167a302a844614e2a2a4f8a4d79c7faed3f98a82f84bb28dcdc296faa5462ba54e7afbf643f61fa893778d0aade1eb23e3daebe5351034f202db277
+ languageName: node
+ linkType: hard
+
"@floating-ui/react-dom@npm:^2.0.6":
version: 2.0.8
resolution: "@floating-ui/react-dom@npm:2.0.8"
@@ -2681,6 +2726,13 @@ __metadata:
languageName: node
linkType: hard
+"@floating-ui/utils@npm:^0.2.4":
+ version: 0.2.4
+ resolution: "@floating-ui/utils@npm:0.2.4"
+ checksum: 10/7662d7a4ae39c0287e026f666297a3d28c80e588251c8c59ff66938a0aead47d380bbb9018629bd63a98f399c3919ec689d5448a5c48ffc176d545ddef705df1
+ languageName: node
+ linkType: hard
+
"@formatjs/ecma402-abstract@npm:1.14.3":
version: 1.14.3
resolution: "@formatjs/ecma402-abstract@npm:1.14.3"
@@ -7557,6 +7609,15 @@ __metadata:
languageName: node
linkType: hard
+"aria-hidden@npm:^1.1.3":
+ version: 1.2.4
+ resolution: "aria-hidden@npm:1.2.4"
+ dependencies:
+ tslib: "npm:^2.0.0"
+ checksum: 10/df4bc15423aaaba3729a7d40abcbf6d3fffa5b8fd5eb33d3ac8b7da0110c47552fca60d97f2e1edfbb68a27cae1da499f1c3896966efb3e26aac4e3b57e3cc8b
+ languageName: node
+ linkType: hard
+
"aria-query@npm:5.1.3":
version: 5.1.3
resolution: "aria-query@npm:5.1.3"
@@ -17008,6 +17069,7 @@ __metadata:
"@babel/preset-typescript": "npm:7.21.5"
"@deanwhillier/jest-matchmedia-mock": "npm:1.2.0"
"@floating-ui/react": "npm:0.26.6"
+ "@floating-ui/react-dom-interactions": "npm:0.13.3"
"@giphy/js-fetch-api": "npm:5.1.0"
"@giphy/react-components": "npm:8.1.0"
"@guyplusplus/turndown-plugin-gfm": "npm:1.0.7"
@@ -23422,6 +23484,13 @@ __metadata:
languageName: node
linkType: hard
+"tslib@npm:^2.0.0":
+ version: 2.6.3
+ resolution: "tslib@npm:2.6.3"
+ checksum: 10/52109bb681f8133a2e58142f11a50e05476de4f075ca906d13b596ae5f7f12d30c482feb0bff167ae01cfc84c5803e575a307d47938999246f5a49d174fc558c
+ languageName: node
+ linkType: hard
+
"tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.5.0, tslib@npm:^2.6.2":
version: 2.6.2
resolution: "tslib@npm:2.6.2"