diff --git a/web/packages/design/src/SVGIcon/Brain.tsx b/web/packages/design/src/SVGIcon/Brain.tsx new file mode 100644 index 0000000000000..940504f3e5d27 --- /dev/null +++ b/web/packages/design/src/SVGIcon/Brain.tsx @@ -0,0 +1,31 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; + +import { SVGIcon } from './SVGIcon'; + +import type { SVGIconProps } from './common'; + +export function BrainIcon({ size = 22, fill }: SVGIconProps) { + return ( + + + + + + ); +} diff --git a/web/packages/teleport/src/Assist/Chat/ChatItem/utils.ts b/web/packages/design/src/SVGIcon/Check.tsx similarity index 64% rename from web/packages/teleport/src/Assist/Chat/ChatItem/utils.ts rename to web/packages/design/src/SVGIcon/Check.tsx index e31a9ac9e012e..44d1d2fbb14f3 100644 --- a/web/packages/teleport/src/Assist/Chat/ChatItem/utils.ts +++ b/web/packages/design/src/SVGIcon/Check.tsx @@ -14,14 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -export function getBorderRadius( - isTeleport: boolean, - isFirst: boolean, - isLast: boolean -) { - if (isTeleport) { - return `${isFirst ? '14px' : '5px'} 14px 14px ${isLast ? '14px' : '5px'}`; - } +import React from 'react'; - return `14px ${isFirst ? '14px' : '5px'} ${isLast ? '14px' : '5px'} 14px`; +import { SVGIcon } from './SVGIcon'; + +import type { SVGIconProps } from './common'; + +export function CheckIcon({ size = 32, fill }: SVGIconProps) { + return ( + + + + ); } diff --git a/web/packages/teleport/src/Assist/ConversationTitle.tsx b/web/packages/design/src/SVGIcon/Expand.tsx similarity index 54% rename from web/packages/teleport/src/Assist/ConversationTitle.tsx rename to web/packages/design/src/SVGIcon/Expand.tsx index 31d51fe6f0e82..4bbb1390c65f8 100644 --- a/web/packages/teleport/src/Assist/ConversationTitle.tsx +++ b/web/packages/design/src/SVGIcon/Expand.tsx @@ -15,23 +15,15 @@ limitations under the License. */ import React from 'react'; -import { useParams } from 'react-router'; -import { useConversations } from 'teleport/Assist/contexts/conversations'; +import { SVGIcon } from './SVGIcon'; -export function ConversationTitle() { - const { conversations } = useConversations(); - const params = useParams<{ conversationId: string }>(); +import type { SVGIconProps } from './common'; - if (params.conversationId) { - const conversation = conversations.find( - conversation => conversation.id === params.conversationId - ); - - if (conversation) { - return <>{conversation.title}; - } - } - - return null; +export function ExpandIcon({ size = 16, fill }: SVGIconProps) { + return ( + + + + ); } diff --git a/web/packages/teleport/src/Assist/Chat/ChatItem/index.ts b/web/packages/design/src/SVGIcon/Popup.tsx similarity index 57% rename from web/packages/teleport/src/Assist/Chat/ChatItem/index.ts rename to web/packages/design/src/SVGIcon/Popup.tsx index 81b6d3e61e542..9d43489268611 100644 --- a/web/packages/teleport/src/Assist/Chat/ChatItem/index.ts +++ b/web/packages/design/src/SVGIcon/Popup.tsx @@ -14,4 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -export { ChatItem } from './ChatItem'; +import React from 'react'; + +import { SVGIcon } from './SVGIcon'; + +import type { SVGIconProps } from './common'; + +export function PopupIcon({ size = 20, fill }: SVGIconProps) { + return ( + + + + ); +} diff --git a/web/packages/design/src/SVGIcon/Sidebar.tsx b/web/packages/design/src/SVGIcon/Sidebar.tsx new file mode 100644 index 0000000000000..7ef3aef9b4394 --- /dev/null +++ b/web/packages/design/src/SVGIcon/Sidebar.tsx @@ -0,0 +1,29 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; + +import { SVGIcon } from './SVGIcon'; + +import type { SVGIconProps } from './common'; + +export function SidebarIcon({ size = 24, fill }: SVGIconProps) { + return ( + + + + ); +} diff --git a/web/packages/design/src/SVGIcon/index.ts b/web/packages/design/src/SVGIcon/index.ts index 905fa3bdd72ec..f3be132b9c496 100644 --- a/web/packages/design/src/SVGIcon/index.ts +++ b/web/packages/design/src/SVGIcon/index.ts @@ -21,7 +21,9 @@ export { ApplicationsIcon } from './Applications'; export { AuditLogIcon } from './AuditLog'; export { AuthConnectorsIcon } from './AuthConnectors'; export { AWSIcon } from './AWS'; +export { BrainIcon } from './Brain'; export { ChatIcon } from './Chat'; +export { CheckIcon } from './Check'; export { ChevronDownIcon } from './ChevronDown'; export { ChevronRightIcon } from './ChevronRight'; export { CloseIcon } from './Close'; @@ -30,6 +32,7 @@ export { DesktopsIcon } from './Desktops'; export { DevicesIcon } from './Devices'; export { DownloadsIcon } from './Downloads'; export { EditIcon } from './Edit'; +export { ExpandIcon } from './Expand'; export { ExternalLinkIcon } from './ExternalLink'; export { IntegrationsIcon } from './Integrations'; export { KubernetesIcon } from './Kubernetes'; @@ -39,6 +42,7 @@ export { LogoutIcon } from './Logout'; export { ManageClustersIcon } from './ManageClusters'; export { OpenAIIcon } from './OpenAI'; export { PlusIcon } from './Plus'; +export { PopupIcon } from './Popup'; export { RemoteCommandIcon } from './RemoteCommand'; export { RolesIcon } from './Roles'; export { RunIcon } from './Run'; @@ -46,6 +50,7 @@ export { SearchIcon } from './Search'; export { ServerIcon } from './Server'; export { ServersIcon } from './Servers'; export { SessionRecordingsIcon } from './SessionRecordings'; +export { SidebarIcon } from './Sidebar'; export { SupportIcon } from './Support'; export { TrustedClustersIcon } from './TrustedClusters'; export { UpgradeIcon } from './Upgrade'; diff --git a/web/packages/shared/package.json b/web/packages/shared/package.json index 090fa04ef29eb..5d7e63be09a5e 100644 --- a/web/packages/shared/package.json +++ b/web/packages/shared/package.json @@ -21,9 +21,11 @@ "react": "^16.8.4", "react-day-picker": "7.3.2", "react-dom": "^16.8.4", + "react-markdown": "^8.0.7", "react-router": "5.1.1", "react-router-dom": "5.1.1", "react-select": "^3.0.8", + "remark-gfm": "^3.0.1", "tslib": "^2.4.0", "whatwg-fetch": "^3.0.0", "@gravitational/design": "1.0.0" diff --git a/web/packages/teleport/src/Assist/Assist.tsx b/web/packages/teleport/src/Assist/Assist.tsx index ebacae54eb444..8acd171585da0 100644 --- a/web/packages/teleport/src/Assist/Assist.tsx +++ b/web/packages/teleport/src/Assist/Assist.tsx @@ -1,69 +1,364 @@ -/* -Copyright 2023 Gravitational, Inc. +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at +import React, { useEffect } from 'react'; +import styled, { keyframes } from 'styled-components'; - http://www.apache.org/licenses/LICENSE-2.0 +import { useLocalStorage } from 'shared/hooks/useLocalStorage'; -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ +import { Header } from 'teleport/Assist/Header'; +import { ConversationHistory } from 'teleport/Assist/ConversationHistory'; +import { AssistContextProvider } from 'teleport/Assist/context/AssistContext'; +import { ConversationList } from 'teleport/Assist/ConversationList'; +import { KeysEnum } from 'teleport/services/localStorage'; +import { useLayout } from 'teleport/Main/LayoutContext'; -import React from 'react'; -import styled from 'styled-components'; +interface AssistProps { + onClose: () => void; +} + +const fadeIn = keyframes` + from { + opacity: 0; + } + + to { + opacity: 1; + } +`; + +const slideIn = keyframes` + from { + transform: translate3d(calc(var(--assist-width) + var(--assist-gutter)), 0, 0); + } + + to { + transform: translate3d(0, 0, 0); + } +`; -import { createPortal } from 'react-dom'; +function variables(props: { viewMode: AssistViewMode }) { + switch (props.viewMode) { + case AssistViewMode.Collapsed: + case AssistViewMode.CollapsedSidebarVisible: + return { + '--assist-gutter': '20px', + '--assist-border-radius': '15px', + '--assist-left': 'auto', + '--assist-right': 'var(--assist-gutter)', + '--assist-width': '550px', + '--assist-height': '780px', + '--assist-box-shadow': '0 30px 60px 0 rgba(0, 0, 0, 0.4)', + '--assist-left-border': 'none', + '--assist-header-height': '59px', + '--assist-bottom-padding': '5px', + }; -import { useParams } from 'react-router'; + case AssistViewMode.Expanded: + case AssistViewMode.ExpandedSidebarVisible: + return { + '--assist-gutter': '20px', + '--assist-border-radius': '15px', + '--assist-left': 'auto', + '--assist-right': 'var(--assist-gutter)', + '--assist-width': '1100px', + '--assist-height': 'calc(100vh - calc(var(--assist-gutter) * 2))', + '--assist-box-shadow': '0 30px 60px 0 rgba(0, 0, 0, 0.4)', + '--assist-left-border': 'none', + '--assist-header-height': '59px', + '--assist-bottom-padding': '5px', + }; -import { MessagesContextProvider } from 'teleport/Assist/contexts/messages'; -import { ConversationsContextProvider } from 'teleport/Assist/contexts/conversations'; -import { ConversationTitle } from 'teleport/Assist/ConversationTitle'; -import { LandingPage } from 'teleport/Assist/LandingPage'; -import { Chat } from 'teleport/Assist/Chat'; -import { Sidebar } from 'teleport/Assist/Sidebar'; + case AssistViewMode.Docked: + case AssistViewMode.DockedSidebarVisible: + return { + '--assist-gutter': '0', + '--assist-border-radius': '0', + '--assist-left': 'auto', + '--assist-right': '0', + '--assist-width': '520px', + '--assist-height': '100vh', + '--assist-box-shadow': 'none', + '--assist-left-border': '1px solid rgba(0, 0, 0, 0.1)', + '--assist-header-height': '72px', + '--assist-bottom-padding': '5px', + }; + } +} + +function sidebarVariables(props: { viewMode: AssistViewMode }) { + switch (props.viewMode) { + case AssistViewMode.Collapsed: + return { + '--conversation-width': '555px', + '--conversation-list-width': '550px', + '--conversation-list-margin': + 'calc((var(--conversation-list-width) * -1) - 4px)', + '--command-input-width': '400px', + '--conversation-list-display': 'none', + '--conversation-list-position': 'absolute', + }; + + case AssistViewMode.CollapsedSidebarVisible: + return { + '--conversation-width': '550px', + '--conversation-list-width': '550px', + '--conversation-list-margin': '0', + '--command-input-width': '400px', + '--conversation-list-display': 'flex', + '--conversation-list-position': 'absolute', + }; + + case AssistViewMode.Expanded: + return { + '--conversation-width': '1100px', + '--conversation-list-width': '250px', + '--conversation-list-margin': + 'calc((var(--conversation-list-width) * -1))', + '--command-input-width': '700px', + '--conversation-list-display': 'none', + '--conversation-list-position': 'absolute', + }; -const Container = styled.div` + case AssistViewMode.ExpandedSidebarVisible: + return { + '--conversation-list-margin': '0', + '--conversation-width': '850px', + '--conversation-list-width': '250px', + '--command-input-width': '600px', + '--conversation-list-display': 'flex', + '--conversation-list-position': 'static', + }; + + case AssistViewMode.Docked: + return { + '--conversation-width': '525px', + '--conversation-list-width': '520px', + '--conversation-list-margin': + 'calc((var(--conversation-list-width) * -1) - 1px)', + '--command-input-width': '380px', + '--conversation-list-display': 'none', + '--conversation-list-position': 'absolute', + }; + + case AssistViewMode.DockedSidebarVisible: + return { + '--conversation-width': '520px', + '--conversation-list-width': '520px', + '--conversation-list-margin': '0', + '--command-input-width': '380px', + '--conversation-list-display': 'flex', + '--conversation-list-position': 'absolute', + }; + } +} + +const Container = styled.div<{ docked: boolean }>` + position: fixed; + top: 0; + left: ${p => (p.docked ? 'auto' : '0')}; + right: 0; + bottom: 0; + opacity: 0; + animation: forwards ${fadeIn} 0.3s ease-in-out; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; display: flex; + justify-content: flex-end; `; -const ChatContainer = styled.div` +const AssistContainer = styled.div` + ${variables}; + ${sidebarVariables}; + + transform: translate3d( + calc(var(--assist-width) + var(--assist-gutter)), + 0, + 0 + ); + animation: forwards ${slideIn} 0.5s cubic-bezier(0.33, 1, 0.68, 1); + transition: width 0.5s cubic-bezier(0.33, 1, 0.68, 1), + height 0.5s cubic-bezier(0.33, 1, 0.68, 1); + background: ${p => p.theme.colors.levels.popout}; + border-radius: var(--assist-border-radius); + box-shadow: var(--assist-box-shadow); + position: absolute; + width: var(--assist-width); + max-height: calc(100vh - var(--assist-gutter) * 2); + height: var(--assist-height); + top: var(--assist-gutter); + right: var(--assist-right); + left: var(--assist-left); + bottom: var(--assist-gutter); display: flex; - max-width: 1600px; - height: calc(100vh - 72px); - width: 100%; + flex-direction: column; + border-left: var(--assist-left-border); + overflow: hidden; `; -export function Assist() { - const params = useParams<{ conversationId: string }>(); +const AssistConversation = styled.div` + display: flex; + flex-direction: column; + width: var(--conversation-width); + overflow-y: auto; + height: 100%; +`; + +const AssistContent = styled.div` + flex: 1; + overflow-y: auto; + overflow-x: hidden; + display: flex; +`; + +export enum AssistViewMode { + Collapsed = 'collapsed', + CollapsedSidebarVisible = 'collapsed-sidebar-visible', + Expanded = 'expanded', + ExpandedSidebarVisible = 'expanded-sidebar-visible', + Docked = 'docked', + DockedSidebarVisible = 'docked-sidebar-visible', +} + +function isDocked(viewMode: AssistViewMode) { + return ( + viewMode === AssistViewMode.Docked || + viewMode === AssistViewMode.DockedSidebarVisible + ); +} + +function isSidebarVisible(viewMode: AssistViewMode) { + return ( + viewMode === AssistViewMode.CollapsedSidebarVisible || + viewMode === AssistViewMode.ExpandedSidebarVisible || + viewMode === AssistViewMode.DockedSidebarVisible + ); +} + +export function Assist(props: AssistProps) { + const [viewMode, setViewMode] = useLocalStorage( + KeysEnum.ASSIST_VIEW_MODE, + AssistViewMode.Collapsed + ); + + const { hasDockedElement, setHasDockedElement } = useLayout(); + + useEffect(() => { + if (!hasDockedElement && isDocked(viewMode)) { + setHasDockedElement(true); + } + + if (hasDockedElement && !isDocked(viewMode)) { + setHasDockedElement(false); + } + }, [hasDockedElement, viewMode]); + + function handleClick(e: React.MouseEvent) { + e.stopPropagation(); + } + + function handleConversationSelect() { + if (viewMode === AssistViewMode.CollapsedSidebarVisible) { + setViewMode(AssistViewMode.Collapsed); + } + + if (viewMode === AssistViewMode.DockedSidebarVisible) { + setViewMode(AssistViewMode.Docked); + } + } + + function handleExpand() { + switch (viewMode) { + case AssistViewMode.Collapsed: + setViewMode(AssistViewMode.Expanded); + + break; + + case AssistViewMode.CollapsedSidebarVisible: + setViewMode(AssistViewMode.ExpandedSidebarVisible); + + break; + + case AssistViewMode.Expanded: + case AssistViewMode.ExpandedSidebarVisible: + setViewMode(AssistViewMode.Collapsed); + + break; + } + } + + function handleDocking() { + switch (viewMode) { + case AssistViewMode.Collapsed: + case AssistViewMode.CollapsedSidebarVisible: + case AssistViewMode.Expanded: + case AssistViewMode.ExpandedSidebarVisible: + setViewMode(AssistViewMode.Docked); + + break; + + case AssistViewMode.Docked: + case AssistViewMode.DockedSidebarVisible: + setViewMode(AssistViewMode.Collapsed); + + break; + } + } + + function handleViewModeChange(viewMode: AssistViewMode) { + setViewMode(viewMode); + } + + function handleClose() { + props.onClose(); + setHasDockedElement(false); + + if (viewMode === AssistViewMode.CollapsedSidebarVisible) { + setViewMode(AssistViewMode.Collapsed); + } + + if (viewMode === AssistViewMode.DockedSidebarVisible) { + setViewMode(AssistViewMode.Docked); + } + } return ( - - {params.conversationId ? ( - - - - - - - - ) : ( - - )} - - {createPortal(, document.getElementById('assist-sidebar'))} - {createPortal( - , - document.getElementById('topbar-portal') - )} - + + + +
+ + {isSidebarVisible(viewMode) && ( + + )} + + + + + + + ); } diff --git a/web/packages/teleport/src/Assist/Chat/Avatar.ts b/web/packages/teleport/src/Assist/Chat/Avatar.ts deleted file mode 100644 index d7c8fb99e7aa1..0000000000000 --- a/web/packages/teleport/src/Assist/Chat/Avatar.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import styled from 'styled-components'; - -export const AvatarContainer = styled.div` - display: flex; - align-items: center; - color: rgba(0, 0, 0, 0.5); - - strong { - display: block; - margin-right: 10px; - color: rgba(0, 0, 0, 0.9); - } -`; - -export const ChatItemAvatarImage = styled.div<{ backgroundImage: string }>` - background: url(${p => p.backgroundImage}) no-repeat; - width: 22px; - height: 22px; - overflow: hidden; - background-size: cover; -`; - -export const ChatItemAvatarTeleport = styled.div` - background: ${props => props.theme.colors.brand}; - padding: 4px; - border-radius: 10px; - left: 0; - right: auto; - margin-right: 10px; -`; diff --git a/web/packages/teleport/src/Assist/Chat/Chat.tsx b/web/packages/teleport/src/Assist/Chat/Chat.tsx deleted file mode 100644 index e64c3b0f1c36c..0000000000000 --- a/web/packages/teleport/src/Assist/Chat/Chat.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import styled from 'styled-components'; - -import teleport from 'design/assets/images/icons/teleport.png'; - -import logger from 'shared/libs/logger'; - -import { Dots } from 'teleport/Assist/Dots'; - -import { - Typing, - TypingContainer, - TypingDot, -} from 'teleport/Assist/Chat/Typing'; - -import { - AvatarContainer, - ChatItemAvatarImage, - ChatItemAvatarTeleport, -} from 'teleport/Assist/Chat/Avatar'; - -import { useConversations } from 'teleport/Assist/contexts/conversations'; - -import { - generateTitle, - setConversationTitle, - useMessages, -} from '../contexts/messages'; - -import { ChatBox } from './ChatBox'; -import { ChatItem } from './ChatItem'; - -const Container = styled.div` - flex: 1; - position: relative; - overflow: hidden; - display: flex; - flex-direction: column; -`; - -const Content = styled.div.attrs({ 'data-scrollbar': 'default' })` - flex: 1 1 auto; - overflow-y: auto; - padding-top: 30px; - display: flex; - justify-content: center; -`; - -const Padding = styled.div` - padding: 30px; - box-sizing: border-box; -`; - -const LoadingContainer = styled.div` - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; -`; - -const Width = styled.div` - max-width: 1200px; - width: 100%; -`; - -class ChatProps { - conversationId: string; -} - -export function Chat(props: ChatProps) { - const ref = useRef(null); - - const [error, setError] = useState(null); - const { - send, - messages, - loading, - responding, - error: messagesError, - } = useMessages(); - const { conversations, setConversations } = useConversations(); - - const scrollTextarea = useCallback(() => { - ref.current?.scrollIntoView({ behavior: 'smooth' }); - }, [ref.current]); - - useEffect(() => { - scrollTextarea(); - }, [messages, scrollTextarea]); - - const handleSubmit = useCallback( - (message: string) => { - send(message).then(() => { - if (messages.length == 1) { - // Use the second message/first message from a user to generate the title. - (async () => { - try { - // Generate title using the last message and OpenAI API. - const title = await generateTitle(message); - // Set the title in the backend. - await setConversationTitle(props.conversationId, title); - // Update the title in the frontend. - setConversations(conversations => - conversations.map(c => { - if (c.id === props.conversationId) { - c.title = title; - } - return c; - }) - ); - } catch (err) { - setError('An error occurred when setting the conversation title'); - - logger.error(err); - } - })(); - } - }); - }, - [messages, conversations, setConversations] - ); - - const items = messages.map((message, index) => ( - - )); - - let content; - if (loading) { - content = ( - - - - ); - } else { - content = ( - - {items} - - {responding && ( - - - - - - - - - - - - - - )} - -
- - ); - } - - return ( - - - {content} - - -
- - - -
-
- ); -} diff --git a/web/packages/teleport/src/Assist/Chat/ChatBox/index.ts b/web/packages/teleport/src/Assist/Chat/ChatBox/index.ts deleted file mode 100644 index 134f7a76376bd..0000000000000 --- a/web/packages/teleport/src/Assist/Chat/ChatBox/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -export { ChatBox } from './ChatBox'; diff --git a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/Action.tsx b/web/packages/teleport/src/Assist/Chat/ChatItem/Action/Action.tsx deleted file mode 100644 index ca575beaa5838..0000000000000 --- a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/Action.tsx +++ /dev/null @@ -1,343 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { ReactElement, useCallback, useState } from 'react'; -import styled from 'styled-components'; - -import { EditIcon, SearchIcon, UserIcon } from 'design/SVGIcon'; - -import Select from 'shared/components/Select'; - -import { ActionState } from 'teleport/Assist/Chat/ChatItem/Action/types'; - -import { ActionForm } from './ActionForm'; -import { Container, Items, Title } from './common'; - -interface ActionProps { - state: ActionState[]; - onStateUpdate: (actionState: ActionState[]) => void; -} - -const Item = styled.div` - padding: 10px 15px; - background: ${p => p.theme.colors.spotBackground[0]}; - border: 1px solid ${p => p.theme.colors.spotBackground[0]}; - border-radius: 5px; - margin-right: 10px; - font-size: 14px; - font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier, - monospace; - font-weight: bold; -`; - -const Buttons = styled.div` - position: absolute; - right: 8px; - top: 8px; - opacity: 0.6; -`; - -const EditButton = styled.div` - border-radius: 10px; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - - &:hover { - background: rgba(255, 255, 255, 0.2); - } -`; - -const Query = styled.div` - padding: 10px 15px; - background: ${p => p.theme.colors.spotBackground[0]}; - border: 1px solid ${p => p.theme.colors.spotBackground[0]}; - border-radius: 5px; - align-items: center; - display: flex; - margin-right: 10px; - font-size: 14px; - font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier, - monospace; - font-weight: bold; - - svg { - margin-right: 10px; - } -`; - -const As = styled.div` - padding: 10px 15px; - margin-left: -10px; -`; - -const User = styled.div` - padding: 10px 15px; - background: ${p => p.theme.colors.spotBackground[0]}; - border: 1px solid ${p => p.theme.colors.spotBackground[0]}; - border-radius: 5px; - display: flex; - align-items: center; - font-weight: bold; - - svg { - margin-right: 10px; - } -`; - -function actionStateToItems(formState: ActionState[]) { - const items = [] as ReactElement[]; - - for (const [index, state] of formState.entries()) { - if (state.type === 'command') { - items.push({state.value}); - } - - if (state.type === 'query') { - items.push( - - - {state.value} - - ); - } - - if (state.type === 'availableUsers') { - items.push( - - as - - - - - - ); - } - } - - return items; -} - -export function NodesAndLabels(props: NodesAndLabelsProps) { - const [editing, setEditing] = useState(false); - - const state = propsToState(props); - - const handleSave = useCallback( - (state: ActionState[]) => { - props.onStateUpdate(state); - setEditing(false); - }, - [props.onStateUpdate] - ); - - if (editing) { - return ( - setEditing(false)} - /> - ); - } - - return ( - - Connect using query - - {!props.disabled && ( - - setEditing(true)}> - - - - )} - - {stateToItems(handleSave, state)} - - ); -} - -interface CommandProps { - command: string; - onStateUpdate: (command: string) => void; - disabled: boolean; -} - -export function Command(props: CommandProps) { - const [editing, setEditing] = useState(false); - - const state: ActionState[] = [{ type: 'command', value: props.command }]; - - const handleSave = useCallback( - (state: ActionState[]) => { - let command = ''; - - for (const item of state) { - if (item.type === 'command') { - command = item.value; - } - } - - props.onStateUpdate(command); - setEditing(false); - }, - [props.onStateUpdate] - ); - - if (editing) { - return ( - setEditing(false)} - /> - ); - } - - return ( - - Execute - - {!props.disabled && ( - - setEditing(true)}> - - - - )} - - {stateToItems(handleSave, state)} - - ); -} diff --git a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/ActionForm.tsx b/web/packages/teleport/src/Assist/Chat/ChatItem/Action/ActionForm.tsx deleted file mode 100644 index 41a9979fa22a9..0000000000000 --- a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/ActionForm.tsx +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Copyright 2023 Gravitational, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { useCallback, useState } from 'react'; -import styled from 'styled-components'; - -import { SearchIcon, UserIcon } from 'design/SVGIcon'; -import { Cross } from 'design/Icon'; - -import Select from 'shared/components/Select'; - -import { ActionState } from 'teleport/Assist/Chat/ChatItem/Action/types'; - -import { Container, Items } from './common'; - -interface ActionFormProps { - initialState: ActionState[]; - onSave: (state: ActionState[]) => void; - onCancel: () => void; -} - -const CommandInput = styled.input` - padding: 10px 15px; - background: ${p => p.theme.colors.spotBackground[0]}; - border-radius: 5px; - font-size: 16px; - font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier, - monospace; - font-weight: bold; - border: none; - color: ${p => p.theme.colors.text.main}; - width: 100%; - box-sizing: border-box; - - &:focus { - outline: none; - } -`; - -const CancelButton = styled.div` - font-weight: bold; - border-radius: 5px; - padding: 5px 15px; - display: inline-flex; - align-self: flex-end; - cursor: pointer; - margin-right: 10px; - - &:hover { - background: ${p => p.theme.colors.spotBackground[0]}; - } -`; - -const SaveButton = styled.div` - margin-top: 10px; - background: ${p => p.theme.colors.buttons.primary.default}; - font-weight: bold; - color: ${p => p.theme.colors.buttons.primary.text}; - border-radius: 5px; - padding: 5px 15px; - display: inline-flex; - align-self: flex-end; - cursor: pointer; - - &:hover { - background: ${p => p.theme.colors.buttons.primary.hover}; - } -`; - -const LabelForm = styled.div` - display: flex; - background: ${p => p.theme.colors.spotBackground[0]}; - align-items: center; - padding: 1px 15px; - border-radius: 5px; - margin-right: 10px; -`; - -const LabelFormContent = styled.div` - display: flex; - align-items: center; -`; - -const Input = styled.input` - background: transparent; - padding: 10px 15px; - border-radius: 5px; - margin-right: 10px; - font-size: 16px; - font-weight: bold; - border: none; - width: 340px; - box-sizing: border-box; - color: ${p => p.theme.colors.text.main}; - - &:focus { - outline: none; - } -`; - -const DeleteButton = styled.div` - padding: 1px 4px; - border-radius: 5px; - cursor: pointer; - justify-self: flex-end; - - &:hover { - background: rgba(255, 255, 255, 0.2); - } -`; - -const FormFooter = styled.div` - display: flex; - justify-content: space-between; - align-items: flex-end; -`; - -const FooterButtons = styled.div``; - -const As = styled.div` - padding: 10px 15px; - margin-left: -10px; -`; - -export function ActionForm(props: ActionFormProps) { - const currentSelectedUser = props.initialState.find(e => e.type === 'user'); - const [formState, setFormState] = useState(props.initialState); - const [currentUser, setCurrentUser] = useState( - currentSelectedUser ? (currentSelectedUser.value as string) : '' - ); - - const handleChange = useCallback((index: number, value: any) => { - setFormState(existing => - existing.map((item, i) => { - if (index === i) { - return { - ...item, - value, - }; - } - - return item; - }) - ); - }, []); - - const handleUserChange = useCallback((index: number, value: string) => { - setCurrentUser(value); - setFormState(existing => - existing.map(item => { - if (item.type === 'user') { - return { - ...item, - value: value, - }; - } - - return item; - }, []) - ); - }, []); - - const handleDelete = useCallback(index => { - setFormState(existing => existing.filter((item, i) => i !== index)); - }, []); - - const items = []; - - for (const [index, stateItem] of formState.entries()) { - if (stateItem.type === 'command') { - items.push( - handleChange(index, event.target.value)} - /> - ); - } - - if (stateItem.type === 'query') { - items.push( - - - - - handleChange(index, event.target.value)} - /> - - - handleDelete(index)}> - - - - ); - } - - if (stateItem.type === 'availableUsers') { - items.push( - as, - - - - setSelectedLogin(event['value'])} + isDisabled={hasRan || props.disabled} + value={{ value: selectedLogin, label: selectedLogin }} + options={availableLogins.map(option => { + return { label: option, value: option }; + })} + css={'width: 150px;'} + /> + + + {errorMessage && {errorMessage}} + + and run + + + + {!command && Command is required} + + {!hasRan && !props.disabled && ( + + Reset + + + Run + + + )} + + ); +} diff --git a/web/packages/teleport/src/Assist/Conversation/Message.tsx b/web/packages/teleport/src/Assist/Conversation/Message.tsx new file mode 100644 index 0000000000000..31f60bef5714e --- /dev/null +++ b/web/packages/teleport/src/Assist/Conversation/Message.tsx @@ -0,0 +1,221 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import styled from 'styled-components'; + +import { CheckIcon } from 'design/SVGIcon'; + +import { + Author, + ResolvedServerMessage, + ServerMessageType, +} from 'teleport/Assist/types'; + +import { + TeleportAvatar, + UserAvatar, +} from 'teleport/Assist/Conversation/Avatar'; +import { + TypingContainer, + TypingDot, +} from 'teleport/Assist/Conversation/Typing'; +import { Timestamp } from 'teleport/Assist/Conversation/Timestamp'; +import { EntryContainer } from 'teleport/Assist/Conversation/EntryContainer'; +import { MessageEntry } from 'teleport/Assist/Conversation/MessageEntry'; +import { useAssist } from 'teleport/Assist/context/AssistContext'; +import { ExecuteRemoteCommandEntry } from 'teleport/Assist/Conversation/ExecuteRemoteCommandEntry'; +import { CommandResultEntry } from 'teleport/Assist/Conversation/CommandResultEntry'; + +import type { ConversationMessage } from 'teleport/Assist/types'; + +interface MessageProps { + message: ConversationMessage; + lastMessage: boolean; +} + +const Container = styled.li` + padding: 0 20px; + margin: 0 0 20px; + flex: 1; + display: flex; + flex-direction: column; +`; + +const Entries = styled.ul` + list-style: none; + padding: 20px 0 5px; + margin: 0; +`; + +const Footer = styled.footer` + display: flex; + align-items: center; + color: ${props => props.theme.colors.text.muted}; + + strong { + display: block; + margin-right: 10px; + color: ${props => props.theme.colors.text.main}; + } +`; + +const TimestampContainer = styled.span` + font-size: 12px; +`; + +const Thought = styled.div` + display: flex; + align-items: center; + margin: 10px 0; + font-size: 13px; + + ${TypingContainer} { + padding: 0; + margin-right: 10px; + } + + ${TypingDot} { + width: 5px; + height: 5px; + margin-right: 5px; + } +`; + +const ThoughtIcon = styled.div` + margin-right: 10px; + height: 16px; +`; + +function createComponentForEntry( + entry: ResolvedServerMessage, + lastMessage: boolean +) { + switch (entry.type) { + case ServerMessageType.Assist: + case ServerMessageType.User: + return ; + + case ServerMessageType.Command: + return ( + + ); + + case ServerMessageType.CommandResultStream: + return ( + + ); + + case ServerMessageType.CommandResult: + return ( + + ); + } +} + +export function Message(props: MessageProps) { + const { messages } = useAssist(); + + const entries = props.message.entries.map((entry, index) => + entry.type === ServerMessageType.AssistThought ? ( + + {index === props.message.entries.length - 1 ? ( + + + + + + ) : ( + + + + )} + + {entry.message} + + ) : ( + + {createComponentForEntry( + entry, + props.lastMessage && index === props.message.entries.length - 1 + )} + + ) + ); + + const authorIsTeleport = props.message.author === Author.Teleport; + const typing = authorIsTeleport && props.lastMessage && messages.streaming; + + return ( + + {entries} + +
+ {authorIsTeleport ? ( + <> + Teleport + + ) : ( + <> + You + + )} + + {typing ? ( + + + + + + ) : ( + + + + )} +
+
+ ); +} diff --git a/web/packages/teleport/src/Assist/Conversation/MessageEntry.tsx b/web/packages/teleport/src/Assist/Conversation/MessageEntry.tsx new file mode 100644 index 0000000000000..0bc2170be472b --- /dev/null +++ b/web/packages/teleport/src/Assist/Conversation/MessageEntry.tsx @@ -0,0 +1,41 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import styled from 'styled-components'; + +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +import { markdownCSS } from 'teleport/Assist/markdown'; + +interface MessageEntryProps { + content: string; +} + +const Container = styled.div` + padding: 10px 15px 0 17px; + + ${markdownCSS} +`; + +export function MessageEntry(props: MessageEntryProps) { + return ( + + {props.content} + + ); +} diff --git a/web/packages/teleport/src/Assist/Chat/Timestamp.tsx b/web/packages/teleport/src/Assist/Conversation/Timestamp.tsx similarity index 90% rename from web/packages/teleport/src/Assist/Chat/Timestamp.tsx rename to web/packages/teleport/src/Assist/Conversation/Timestamp.tsx index a4446581e737b..f5dbc4a8a477b 100644 --- a/web/packages/teleport/src/Assist/Chat/Timestamp.tsx +++ b/web/packages/teleport/src/Assist/Conversation/Timestamp.tsx @@ -19,11 +19,10 @@ import React, { useEffect, useState } from 'react'; import { formatRelative } from 'date-fns'; interface TimestampProps { - isoTimestamp: string; + timestamp: Date; } export function Timestamp(props: TimestampProps) { - const [date] = useState(() => new Date(props.isoTimestamp)); const [, setCounter] = useState(0); useEffect(() => { @@ -37,7 +36,11 @@ export function Timestamp(props: TimestampProps) { }; }, []); - return {formatDate(date)}; + return ( + + {formatDate(props.timestamp)} + + ); } function formatDate(date: Date) { diff --git a/web/packages/teleport/src/Assist/Chat/Typing.ts b/web/packages/teleport/src/Assist/Conversation/Typing.ts similarity index 100% rename from web/packages/teleport/src/Assist/Chat/Typing.ts rename to web/packages/teleport/src/Assist/Conversation/Typing.ts diff --git a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/types.ts b/web/packages/teleport/src/Assist/Conversation/index.ts similarity index 60% rename from web/packages/teleport/src/Assist/Chat/ChatItem/Action/types.ts rename to web/packages/teleport/src/Assist/Conversation/index.ts index b0d05a6bb87b9..36bce8f1ec33f 100644 --- a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/types.ts +++ b/web/packages/teleport/src/Assist/Conversation/index.ts @@ -14,28 +14,4 @@ * limitations under the License. */ -export interface CommandState { - type: 'command'; - value: string; -} - -export interface QueryState { - type: 'query'; - value: string; -} - -export interface UserState { - type: 'user'; - value: string; -} - -export interface AvailableUsersState { - type: 'availableUsers'; - value: string[]; -} - -export type ActionState = - | CommandState - | QueryState - | UserState - | AvailableUsersState; +export { Conversation } from './Conversation'; diff --git a/web/packages/teleport/src/Assist/ConversationHistory/ConversationHistory.tsx b/web/packages/teleport/src/Assist/ConversationHistory/ConversationHistory.tsx new file mode 100644 index 0000000000000..345234311f4aa --- /dev/null +++ b/web/packages/teleport/src/Assist/ConversationHistory/ConversationHistory.tsx @@ -0,0 +1,173 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import styled from 'styled-components'; + +import { ButtonPrimary } from 'design'; + +import { useAssist } from 'teleport/Assist/context/AssistContext'; +import { ConversationHistoryItem } from 'teleport/Assist/ConversationHistory/ConversationHistoryItem'; +import { AssistViewMode } from 'teleport/Assist/Assist'; +import { DeleteConversationDialog } from 'teleport/Assist/ConversationHistory/DeleteConversationDialog'; + +interface ConversationHistoryProps { + onConversationSelect: (id: string) => void; + viewMode: AssistViewMode; +} + +const Container = styled.ul.attrs({ 'data-scrollbar': 'default' })` + border-right: 1px solid ${p => p.theme.colors.spotBackground[0]}; + display: flex; + flex-direction: column; + gap: 5px; + box-sizing: border-box; + list-style: none; + padding: 0; + width: var(--conversation-list-width); + margin: 0; + position: var(--conversation-list-position); + top: var(--assist-header-height); + bottom: 0; + background: ${p => p.theme.colors.levels.popout}; + z-index: 999; +`; + +const List = styled.ul.attrs({ 'data-scrollbar': 'default' })` + display: flex; + padding: 10px 10px; + width: 100%; + flex-direction: column; + gap: 5px; + box-sizing: border-box; + list-style: none; + overflow-y: auto; +`; + +const NewConversationButton = styled.li` + margin: 10px 10px 0; + + button { + width: 100%; + } +`; + +const ErrorMessage = styled.div` + background: ${props => props.theme.colors.error.main}; + color: white; + border-radius: 5px; + margin-bottom: 10px; + padding: 5px 10px; +`; + +function isExpanded(viewMode: AssistViewMode) { + return ( + viewMode === AssistViewMode.Expanded || + viewMode === AssistViewMode.ExpandedSidebarVisible + ); +} + +export function ConversationHistory(props: ConversationHistoryProps) { + const { + conversations, + createConversation, + deleteConversation, + setSelectedConversationId, + } = useAssist(); + + const [errorMessage, setErrorMessage] = useState(null); + const [deleting, setDeleting] = useState(false); + const [deleteErrorMessage, setDeleteErrorMessage] = + useState(null); + const [conversationIdToDelete, setConversationIdToDelete] = + useState(null); + + async function handleSelectConversation(id: string) { + try { + props.onConversationSelect(id); + + await setSelectedConversationId(id); + } catch (err) { + setErrorMessage(err.message); + } + } + + async function handleDelete() { + setDeleteErrorMessage(null); + setDeleting(true); + + try { + await deleteConversation(conversationIdToDelete); + + setConversationIdToDelete(null); + } catch (err) { + setDeleteErrorMessage(err.message); + } + + setDeleting(false); + } + + async function handleCreateNewConversation() { + try { + const id = await createConversation(); + + props.onConversationSelect(id); + } catch (err) { + setErrorMessage(err.message); + } + } + + const conversationToDelete = conversations.data.find( + conversation => conversation.id === conversationIdToDelete + ); + + const items = conversations.data.map(conversation => ( + handleSelectConversation(conversation.id)} + onDelete={() => setConversationIdToDelete(conversation.id)} + /> + )); + + return ( + + {conversationIdToDelete && ( + setConversationIdToDelete(null)} + disabled={deleting} + error={deleteErrorMessage} + /> + )} + + + {errorMessage && {errorMessage}} + + handleCreateNewConversation()}> + New conversation + + + + {items} + + ); +} diff --git a/web/packages/teleport/src/Assist/ConversationHistory/ConversationHistoryItem.tsx b/web/packages/teleport/src/Assist/ConversationHistory/ConversationHistoryItem.tsx new file mode 100644 index 0000000000000..9803f4f126d00 --- /dev/null +++ b/web/packages/teleport/src/Assist/ConversationHistory/ConversationHistoryItem.tsx @@ -0,0 +1,106 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import styled from 'styled-components'; + +import { CloseIcon } from 'design/SVGIcon'; + +import type { Conversation } from 'teleport/Assist/types'; + +interface ConversationHistoryItemProps { + conversation: Conversation; + active: boolean; + onSelect: () => void; + onDelete: (id: string) => void; +} + +const Buttons = styled.div` + position: absolute; + right: 5px; + top: 0; + bottom: 0; + display: none; + align-items: center; + justify-content: center; +`; + +const Container = styled.li<{ active: boolean }>` + margin: 0; + padding: 5px 11px; + display: flex; + position: relative; + justify-content: space-between; + border-radius: 5px; + cursor: pointer; + background: ${p => (p.active ? p.theme.colors.spotBackground[0] : 'none')}; + + &:hover { + background: ${p => p.theme.colors.spotBackground[0]}; + padding-right: 36px; + + ${Buttons} { + display: flex; + } + } + + --title-weight: ${p => (p.active ? 600 : 400)}; +`; + +const Title = styled.h3` + margin: 0; + font-weight: var(--title-weight); + font-size: 15px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +`; + +const DeleteButton = styled.div` + cursor: pointer; + padding: 5px 5px 0; + background: ${p => p.theme.colors.spotBackground[0]}; + border-radius: 7px; + + &:hover { + background: ${p => p.theme.colors.error.main}; + + svg path { + stroke: white; + } + } +`; + +export function ConversationHistoryItem(props: ConversationHistoryItemProps) { + function handleDelete(event: React.MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + props.onDelete(props.conversation.id); + } + + return ( + + {props.conversation.title} + + + + + + + + ); +} diff --git a/web/packages/teleport/src/Assist/Sidebar/DeleteConversationDialog.tsx b/web/packages/teleport/src/Assist/ConversationHistory/DeleteConversationDialog.tsx similarity index 100% rename from web/packages/teleport/src/Assist/Sidebar/DeleteConversationDialog.tsx rename to web/packages/teleport/src/Assist/ConversationHistory/DeleteConversationDialog.tsx index 720ee814dfcfa..73fff1fa69dd2 100644 --- a/web/packages/teleport/src/Assist/Sidebar/DeleteConversationDialog.tsx +++ b/web/packages/teleport/src/Assist/ConversationHistory/DeleteConversationDialog.tsx @@ -45,6 +45,7 @@ export function DeleteConversationDialog(props: DeleteConversationDialogProps) { Are you sure? + {props.error && {props.error}}

You are about to delete the conversation{' '} @@ -54,7 +55,6 @@ export function DeleteConversationDialog(props: DeleteConversationDialogProps) { You will not be able to access the conversation afterwards.

- {props.error && {props.error}} (); + + const scrolling = useRef(false); + + const { conversations, selectedConversationMessages } = useAssist(); + + function scroll() { + scrolling.current = true; + + ref.current.scrollIntoView({ behavior: 'smooth' }); + + window.setTimeout(() => (scrolling.current = false), 1000); + } + + useEffect(() => { + if (!ref.current || scrolling.current) { + return; + } + + scroll(); + }, [selectedConversationMessages, scrolling.current]); + + useLayoutEffect(() => { + if (!ref.current || scrolling.current) { + return; + } + + const id = window.setTimeout(scroll, 500); + + return () => window.clearTimeout(id); + }, [props.viewMode, scrolling.current]); + + if (!conversations.selectedId) { + return ; + } + + return ( + <> + + + +
+ + + + ); +} diff --git a/web/packages/teleport/src/Assist/ConversationList/index.ts b/web/packages/teleport/src/Assist/ConversationList/index.ts new file mode 100644 index 0000000000000..bdc7f2756f6c1 --- /dev/null +++ b/web/packages/teleport/src/Assist/ConversationList/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ConversationList } from './ConversationList'; diff --git a/web/packages/teleport/src/Assist/Dots.tsx b/web/packages/teleport/src/Assist/Dots.tsx deleted file mode 100644 index 05646f72bd3fc..0000000000000 --- a/web/packages/teleport/src/Assist/Dots.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import styled, { keyframes } from 'styled-components'; - -const loader = keyframes` - 0% { - transform: translateX(0); - } - 50% { - transform: translateX(1420%); - } - 100% { - transform: translateX(0); - } -`; - -const DotsContainer = styled.div` - position: relative; - width: 100%; - padding-top: 6.6%; -`; - -const Container = styled.div` - box-sizing: border-box; - width: 100px; - height: 5px; - - *, - *:before, - *:after { - box-sizing: inherit; - } -`; - -const Dot = styled.div` - width: 6.6%; - padding-top: 6.6%; - animation: ${loader} 2s ease-in-out infinite; - border-radius: 100%; - display: inline-block; - position: absolute; - top: 0; - left: 0; -`; - -export function Dots() { - let dots = new Array(6).fill('').map((e, index) => { - return ( - - ); - }); - - return ( - - {dots} - - ); -} diff --git a/web/packages/teleport/src/Assist/Header/Header.tsx b/web/packages/teleport/src/Assist/Header/Header.tsx new file mode 100644 index 0000000000000..95289dc7e7940 --- /dev/null +++ b/web/packages/teleport/src/Assist/Header/Header.tsx @@ -0,0 +1,171 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import styled from 'styled-components'; + +import { + ChevronRightIcon, + CloseIcon, + ExpandIcon, + PopupIcon, + SidebarIcon, +} from 'design/SVGIcon'; + +import { AssistViewMode } from 'teleport/Assist/Assist'; +import { useAssist } from 'teleport/Assist/context/AssistContext'; + +interface HeaderProps { + viewMode: AssistViewMode; + onClose: () => void; + onExpand: () => void; + onDocking: () => void; + onViewModeChange: (viewMode: AssistViewMode) => void; +} + +const Container = styled.header` + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 15px; + border-bottom: 1px solid ${p => p.theme.colors.spotBackground[0]}; + user-select: none; + flex: 0 0 var(--assist-header-height); + box-sizing: border-box; +`; + +const Icon = styled.div` + border-radius: 7px; + width: 38px; + height: 38px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: 0.2s ease-in-out opacity; + + svg { + transform: ${p => (p.rotated ? 'rotate(180deg)' : 'none')}; + } + + &:hover { + background: ${p => p.theme.colors.spotBackground[0]}; + } +`; + +const Icons = styled.section` + display: flex; + align-items: center; + flex: 0 0 100px; +`; + +const Title = styled.h2` + margin: 0; + font-size: 16px; +`; + +function isExpanded(viewMode: AssistViewMode) { + return ( + viewMode === AssistViewMode.Expanded || + viewMode === AssistViewMode.ExpandedSidebarVisible + ); +} + +function toggleSidebarVisible(viewMode: AssistViewMode) { + switch (viewMode) { + case AssistViewMode.Expanded: + return AssistViewMode.ExpandedSidebarVisible; + + case AssistViewMode.ExpandedSidebarVisible: + return AssistViewMode.Expanded; + + case AssistViewMode.Docked: + return AssistViewMode.DockedSidebarVisible; + + case AssistViewMode.DockedSidebarVisible: + return AssistViewMode.Docked; + + case AssistViewMode.Collapsed: + return AssistViewMode.CollapsedSidebarVisible; + + case AssistViewMode.CollapsedSidebarVisible: + return AssistViewMode.Collapsed; + } +} + +function isDocked(viewMode: AssistViewMode) { + return ( + viewMode === AssistViewMode.Docked || + viewMode === AssistViewMode.DockedSidebarVisible + ); +} + +export function Header(props: HeaderProps) { + const { + conversations: { selectedId, data }, + } = useAssist(); + + const title = selectedId + ? data.find(conversation => conversation.id === selectedId)?.title + : 'Teleport Assist'; + + return ( + + + {(props.viewMode === AssistViewMode.Collapsed || + props.viewMode === AssistViewMode.Docked) && ( + + props.onViewModeChange(toggleSidebarVisible(props.viewMode)) + } + > + + + )} + {isExpanded(props.viewMode) && ( + + props.onViewModeChange(toggleSidebarVisible(props.viewMode)) + } + > + + + )} + + + {title} + + + {!isDocked(props.viewMode) && ( + + + + )} + + {!isDocked(props.viewMode) ? ( + + ) : ( + + )} + + props.onClose()}> + + + + + ); +} diff --git a/web/packages/teleport/src/Assist/Header/index.ts b/web/packages/teleport/src/Assist/Header/index.ts new file mode 100644 index 0000000000000..e04a14e7a6842 --- /dev/null +++ b/web/packages/teleport/src/Assist/Header/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Header } from './Header'; diff --git a/web/packages/teleport/src/Assist/LandingPage.tsx b/web/packages/teleport/src/Assist/LandingPage/LandingPage.tsx similarity index 76% rename from web/packages/teleport/src/Assist/LandingPage.tsx rename to web/packages/teleport/src/Assist/LandingPage/LandingPage.tsx index 117f74a868ac8..5cc09aee94c18 100644 --- a/web/packages/teleport/src/Assist/LandingPage.tsx +++ b/web/packages/teleport/src/Assist/LandingPage/LandingPage.tsx @@ -1,20 +1,20 @@ -/* -Copyright 2023 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { useCallback } from 'react'; +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; import styled, { useTheme } from 'styled-components'; import { @@ -25,28 +25,23 @@ import { ServerIcon, } from 'design/SVGIcon'; -import { useHistory } from 'react-router'; - import Flex from 'design/Flex'; import Link from 'design/Link'; -import cfg from 'teleport/config'; -import { useConversations } from 'teleport/Assist/contexts/conversations'; +import { useAssist } from 'teleport/Assist/context/AssistContext'; const Container = styled.div` display: flex; - flex: 1; justify-content: center; `; const Content = styled.div` background: ${p => p.theme.colors.levels.popout}; color: ${p => p.theme.colors.text.main}; - font-size: 15px; + font-size: 14px; padding: 20px 25px; border-radius: 7px; - margin-top: 50px; width: 700px; `; @@ -116,15 +111,7 @@ const NewChatButton = styled.div` export function LandingPage() { const theme = useTheme(); - const history = useHistory(); - - const { create } = useConversations(); - - const handleNewChat = useCallback(() => { - create().then(conversationId => - history.push(cfg.getAssistConversationUrl(conversationId)) - ); - }, []); + const { createConversation } = useAssist(); return ( @@ -185,7 +172,7 @@ export function LandingPage() { - handleNewChat()}> + createConversation()}> Start a new conversation diff --git a/web/packages/teleport/src/Assist/LandingPage/index.ts b/web/packages/teleport/src/Assist/LandingPage/index.ts new file mode 100644 index 0000000000000..572eb3a5bd638 --- /dev/null +++ b/web/packages/teleport/src/Assist/LandingPage/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { LandingPage } from './LandingPage'; diff --git a/web/packages/teleport/src/Assist/Chat/ChatBox/ChatBox.tsx b/web/packages/teleport/src/Assist/MessageBox/MessageBox.tsx similarity index 64% rename from web/packages/teleport/src/Assist/Chat/ChatBox/ChatBox.tsx rename to web/packages/teleport/src/Assist/MessageBox/MessageBox.tsx index 4c4c3e97ee41b..c75c93fb9344e 100644 --- a/web/packages/teleport/src/Assist/Chat/ChatBox/ChatBox.tsx +++ b/web/packages/teleport/src/Assist/MessageBox/MessageBox.tsx @@ -18,21 +18,52 @@ import React, { ChangeEvent, KeyboardEvent, useEffect, + useLayoutEffect, useRef, useState, } from 'react'; -import styled from 'styled-components'; +import styled, { keyframes } from 'styled-components'; -import { useMessages } from 'teleport/Assist/contexts/messages'; +import { useAssist } from 'teleport/Assist/context/AssistContext'; -interface ChatBoxProps { +const spin = keyframes` + to { + transform: rotate(360deg); + } +`; + +interface MessageBoxProps { disabled?: boolean; - onSubmit: (value: string) => void; errorMessage: string | null; } const Container = styled.div` - padding: 0 30px 30px; + padding: 0 15px var(--assist-bottom-padding) 15px; + position: relative; +`; + +const Spinner = styled.div` + width: 20px; + height: 20px; + + &:after { + content: ' '; + display: block; + width: 12px; + height: 12px; + margin: 8px; + border-radius: 50%; + border: 3px solid ${p => p.theme.colors.text.main}; + border-color: ${p => p.theme.colors.text.main} transparent + ${p => p.theme.colors.text.main} transparent; + animation: ${spin} 1.2s linear infinite; + } +`; + +const SpinnerContainer = styled.div` + position: absolute; + top: 12px; + right: 40px; `; const TextArea = styled.textarea` @@ -42,10 +73,11 @@ const TextArea = styled.textarea` border: 2px solid ${props => props.theme.colors.spotBackground[1]}; border-radius: 10px; resize: none; - padding: 20px 20px 5px 30px; - font-size: 16px; - line-height: 24px; + padding: 17px 20px 1px 20px; + font-size: 14px; + line-height: 18px; box-sizing: border-box; + overflow-y: hidden; &:focus { outline: none; @@ -63,11 +95,11 @@ const ErrorMessage = styled.div` margin-bottom: 5px; `; -export function ChatBox(props: ChatBoxProps) { +export function MessageBox(props: MessageBoxProps) { const [value, setValue] = useState(''); const ref = useRef(null); - const { responding } = useMessages(); + const { conversations, messages, sendMessage } = useAssist(); useEffect(() => { if (ref.current) { @@ -82,7 +114,13 @@ export function ChatBox(props: ChatBoxProps) { if (ref.current) { ref.current.focus(); } - }, [props.disabled]); + }, [props.disabled, ref.current]); + + useLayoutEffect(() => { + if (ref.current) { + ref.current.focus(); + } + }, [conversations.selectedId, ref.current]); function handleChange(event: ChangeEvent) { setValue(event.target.value); @@ -93,8 +131,8 @@ export function ChatBox(props: ChatBoxProps) { event.preventDefault(); event.stopPropagation(); - if (!responding && value) { - props.onSubmit(value); + if (!messages.streaming && value) { + sendMessage(value); setValue(''); } } @@ -104,6 +142,12 @@ export function ChatBox(props: ChatBoxProps) { {props.errorMessage && {props.errorMessage}} + {messages.streaming && ( + + + + )} +