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 Action(props: ActionProps) {
- const [editing, setEditing] = useState(false);
-
- const handleSave = useCallback(
- (state: ActionState[]) => {
- props.onStateUpdate(state);
- setEditing(false);
- },
- [props.onStateUpdate]
- );
-
- if (editing) {
- return (
- setEditing(false)}
- />
- );
- }
-
- const items = actionStateToItems(props.state);
-
- return (
-
-
- setEditing(true)}>
-
-
-
-
- {items}
-
- );
-}
-
-interface NodesAndLabelsProps {
- initialQuery: string | undefined;
- selectedLogin: string | undefined;
- availableLogins: string[] | undefined;
- onStateUpdate: (state: ActionState[]) => void;
- disabled: boolean;
-}
-
-function propsToState(props: NodesAndLabelsProps): ActionState[] {
- const items: ActionState[] = [];
-
- // Always include query.
- items.push({ type: 'query', value: props.initialQuery ?? '' });
-
- if (props.availableLogins) {
- items.push({ type: 'availableUsers', value: props.availableLogins });
- }
-
- if (props.selectedLogin) {
- items.push({ type: 'user', value: props.selectedLogin });
- }
-
- return items;
-}
-
-function stateToItems(
- updateUser: (state: ActionState[]) => void,
- formState: ActionState[]
-) {
- const items = [];
-
- for (const [index, state] of formState.entries()) {
- if (state.type === 'command') {
- items.push({state.value});
- }
-
- if (state.type === 'query') {
- items.push(
-
-
- {state.value}
-
- );
- }
-
- const handleChange = event => {
- updateUser([...formState, { type: 'user', value: event.value }]);
- };
-
- if (state.type === 'user') {
- 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,
-
-
-
-
-
- handleDelete(index)}>
-
-
-
- );
- }
- }
-
- return (
-
- {items}
-
-
-
- props.onCancel()}>Cancel
- props.onSave(formState)}>Save
-
-
-
- );
-}
diff --git a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/Actions.tsx b/web/packages/teleport/src/Assist/Chat/ChatItem/Action/Actions.tsx
deleted file mode 100644
index 127681d544dcc..0000000000000
--- a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/Actions.tsx
+++ /dev/null
@@ -1,196 +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, useState } from 'react';
-import styled, { useTheme } from 'styled-components';
-
-import { RunIcon } from 'design/SVGIcon';
-
-import { ActionState } from 'teleport/Assist/Chat/ChatItem/Action/types';
-import {
- Command,
- NodesAndLabels,
-} from 'teleport/Assist/Chat/ChatItem/Action/Action';
-import { RunCommand } from 'teleport/Assist/Chat/ChatItem/Action/RunAction';
-import useStickyClusterId from 'teleport/useStickyClusterId';
-
-import ErrorMessage from 'teleport/components/AgentErrorMessage';
-
-import { remoteCommandToMessage } from 'teleport/Assist/contexts/messages';
-
-import { ExecuteRemoteCommandContent, Type } from '../../../services/messages';
-
-interface ActionsProps {
- actions: ExecuteRemoteCommandContent;
- scrollTextarea: () => void;
- showRunButton: boolean;
-}
-
-const Container = styled.div`
- width: 100%;
- min-width: 500px;
- padding-bottom: 15px;
-`;
-
-const Title = styled.div`
- font-size: 15px;
- margin-bottom: 10px;
-`;
-
-const Buttons = styled.div`
- display: flex;
- justify-content: flex-end;
- margin-top: 20px;
-`;
-
-const Button = styled.div`
- display: flex;
- padding: 5px 15px 5px 10px;
- border-radius: 5px;
- font-weight: bold;
- font-size: 15px;
- align-items: center;
- margin-left: 10px;
- cursor: pointer;
-
- svg {
- margin-right: 10px;
- }
-`;
-
-const ButtonRun = styled(Button)<{ disabled: boolean }>`
- border: 2px solid ${p => (p.disabled ? '#cccccc' : '#20b141')};
- opacity: ${p => (p.disabled ? 0.8 : 1)};
- cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
- color: ${p => (p.theme.name === 'light' ? '#20b141' : 'white')};
-
- &:hover {
- background: ${p => (p.disabled ? 'none' : '#20b141')};
- color: white;
-
- svg,
- path {
- fill: white;
- }
- }
-`;
-
-const Spacer = styled.div`
- text-align: center;
- padding: 10px 0;
- font-size: 14px;
-`;
-
-export function Actions(props: ActionsProps) {
- const theme = useTheme();
-
- const [running, setRunning] = useState(false);
- const [actions, setActions] = useState({ ...props.actions });
- const { clusterId } = useStickyClusterId();
-
- const [result] = useState(false);
-
- useEffect(() => {
- props.scrollTextarea();
- }, [running, props.scrollTextarea]);
-
- const run = useCallback(async () => {
- if (running) {
- return;
- }
-
- setRunning(true);
- }, [running]);
-
- const handleSave = useCallback(
- (newActionState: ActionState[]) => {
- const newActions: ExecuteRemoteCommandContent = {
- type: Type.ExecuteRemoteCommand,
- selectedLogin: '',
- availableLogins: [],
- query: '',
- command: actions.command,
- errorMsg: '',
- };
-
- for (const item of newActionState) {
- if (item.type == 'query') {
- newActions.query = item.value;
- }
-
- if (item.type === 'user') {
- newActions.selectedLogin = item.value;
- }
-
- if (item.type === 'availableUsers') {
- newActions.availableLogins = item.value;
- }
- }
-
- remoteCommandToMessage(newActions, clusterId).then(e => setActions(e));
- },
- [actions]
- );
-
- const handleCommandUpdate = useCallback(
- (newCommand: string) => {
- const newActions: ExecuteRemoteCommandContent = {
- ...actions,
- command: newCommand,
- };
-
- setActions(newActions);
- },
- [actions]
- );
-
- return (
-
- {actions.errorMsg && }
- {!result && Teleport would like to}
-
-
-
- and
-
-
-
- {!result && !running && props.showRunButton && (
-
- run()}>
-
- Run
-
-
- )}
-
- {running && }
-
- );
-}
diff --git a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/ExecResult.tsx b/web/packages/teleport/src/Assist/Chat/ChatItem/Action/ExecResult.tsx
deleted file mode 100644
index 5ba2bf8ef4245..0000000000000
--- a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/ExecResult.tsx
+++ /dev/null
@@ -1,116 +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, { useState } from 'react';
-import styled from 'styled-components';
-
-import { ExecOutput } from '../../../services/messages';
-
-interface ExecResultProps {
- result: ExecOutput;
-}
-
-const Container = styled.div`
- margin-top: 20px;
- margin-bottom: 40px;
- background: rgba(0, 0, 0, 0.2);
- padding: 15px 20px;
- border-radius: 10px;
- font-size: 18px;
- position: relative;
-`;
-
-const Title = styled.div`
- font-size: 15px;
- margin-bottom: 15px;
-`;
-
-const ActionButton = styled.div`
- font-size: 14px;
- background: rgba(0, 0, 0, 0.7);
- padding: 5px 10px;
- position: absolute;
- top: 10px;
- right: 15px;
- border-radius: 5px;
- cursor: pointer;
- &:hover {
- text-decoration: underline;
- }
-`;
-
-const CommandOutput = styled.div`
- margin-bottom: 15px;
-
- &:last-of-type {
- margin-bottom: 0;
- }
-`;
-
-const MachineName = styled.div`
- font-size: 15px;
- margin-bottom: 5px;
-`;
-
-const Output = styled.div`
- background: rgba(255, 255, 255, 0.1);
- border-radius: 5px;
- padding: 5px 10px;
- font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier,
- monospace;
- font-size: 16px;
-`;
-
-export function ExecResult(props: ExecResultProps) {
- const [showOutput, setShowOutput] = useState(false);
-
- if (showOutput) {
- const items = [];
-
- for (const [index, item] of props.result.commandOutputs.entries()) {
- items.push(
-
- {item.serverName}
-
-
- );
- }
-
- return (
-
- Output
-
- setShowOutput(false)}>
- Show result
-
-
- {items}
-
- );
- }
-
- return (
-
- Result
-
- setShowOutput(true)}>
- Show output
-
-
- {props.result.humanInterpretation}
-
- );
-}
diff --git a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/RunAction.tsx b/web/packages/teleport/src/Assist/Chat/ChatItem/Action/RunAction.tsx
deleted file mode 100644
index df7deabc2b857..0000000000000
--- a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/RunAction.tsx
+++ /dev/null
@@ -1,287 +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 { useParams } from 'react-router';
-
-import useStickyClusterId from 'teleport/useStickyClusterId';
-import { getAccessToken, getHostName } from 'teleport/services/api';
-
-import { ExecuteRemoteCommandContent } from 'teleport/Assist/services/messages';
-import { MessageTypeEnum, Protobuf } from 'teleport/lib/term/protobuf';
-import { Dots } from 'teleport/Assist/Dots';
-import cfg from 'teleport/config';
-import { WebauthnAssertionResponse } from 'teleport/services/auth';
-import useWebAuthn from 'teleport/lib/useWebAuthn';
-import { EventEmitterWebAuthnSender } from 'teleport/lib/EventEmitterWebAuthnSender';
-import AuthnDialog from 'teleport/components/AuthnDialog';
-import { TermEvent } from 'teleport/lib/term/enums';
-
-interface RunCommandProps {
- actions: ExecuteRemoteCommandContent;
-}
-
-function convertContentToCommand(message: ExecuteRemoteCommandContent) {
- const command = {
- command: '',
- login: '',
- query: '',
- };
-
- if (message.selectedLogin) {
- command.login = message.selectedLogin;
- }
-
- if (message.command) {
- command.command = message.command;
- }
-
- if (message.query) {
- command.query = message.query;
- }
-
- return command;
-}
-
-enum RunActionStatus {
- Connecting,
- Finished,
-}
-
-interface NodeState {
- nodeId: string;
- status: RunActionStatus;
- stdout?: string;
-}
-
-interface RawPayload {
- node_id: string;
- payload: string;
-}
-
-interface SessionData {
- session: { server_id: string };
-}
-
-class assistClient extends EventEmitterWebAuthnSender {
- private readonly ws: WebSocket;
- readonly proto: Protobuf = new Protobuf();
- readonly encoder = new window.TextEncoder();
-
- constructor(
- url: string,
- setState: React.Dispatch>
- ) {
- super();
-
- this.ws = new WebSocket(url);
- this.ws.binaryType = 'arraybuffer';
-
- this.ws.onclose = () => {
- setState(state => {
- return state.map(n => ({
- ...n,
- stdout: n.stdout || '',
- status: RunActionStatus.Finished,
- }));
- });
- };
-
- this.ws.onmessage = event => {
- const uintArray = new Uint8Array(event.data);
- const msg = this.proto.decode(uintArray);
-
- switch (msg.type) {
- case MessageTypeEnum.SESSION_DATA:
- const sessionData = JSON.parse(msg.payload) as SessionData;
- setState(state => {
- state.push({
- nodeId: sessionData.session.server_id,
- status: RunActionStatus.Connecting,
- });
- return state;
- });
- break;
-
- case MessageTypeEnum.RAW:
- const data = JSON.parse(msg.payload) as RawPayload;
- const payload = atob(data.payload);
-
- setState(state => {
- const results = state.find(node => node.nodeId == data.node_id);
- if (!results) {
- state.push({
- nodeId: data.node_id,
- status: RunActionStatus.Connecting,
- });
- }
-
- return state.map(item => {
- if (item.nodeId === data.node_id) {
- if (!item.stdout) {
- item.stdout = '';
- }
- return {
- ...item,
- status: RunActionStatus.Finished,
- stdout: item.stdout + payload,
- };
- }
-
- return item;
- });
- });
-
- break;
- case MessageTypeEnum.ERROR:
- console.error(msg.payload);
- break;
- case MessageTypeEnum.WEBAUTHN_CHALLENGE:
- this.emit(TermEvent.WEBAUTHN_CHALLENGE, msg.payload);
- break;
- }
- };
- }
-
- sendWebAuthn(data: WebauthnAssertionResponse) {
- const msg = this.encoder.encode(JSON.stringify(data));
- this.send(msg);
- }
-
- send(data) {
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !data) {
- console.warn('websocket unavailable', this.ws, data);
- return;
- }
-
- const msg = this.proto.encodeRawMessage(data);
- const bytearray = new Uint8Array(msg);
- this.ws.send(bytearray.buffer);
- }
-}
-
-export function RunCommand(props: RunCommandProps) {
- const { clusterId } = useStickyClusterId();
- const urlParams = useParams<{ conversationId: string }>();
-
- const [state, setState] = useState(() => []);
-
- const params = convertContentToCommand(props.actions);
-
- const execParams = {
- ...params,
- conversation_id: urlParams.conversationId,
- execution_id: crypto.randomUUID(),
- };
-
- const url = cfg.getAssistExecuteCommandUrl(
- getHostName(),
- clusterId,
- getAccessToken(),
- execParams
- );
-
- const [assistClt] = useState(() => new assistClient(url, setState));
- const webauthn = useWebAuthn(assistClt);
-
- const cancelCallback = useCallback(() => {
- webauthn.setState(prevState => {
- return {
- ...prevState,
- requested: false,
- };
- });
- }, [webauthn]);
-
- const nodes = state.map((item, index) => (
-
- ));
-
- return (
- <>
- {webauthn.requested && (
-
- )}
-
{nodes}
- >
- );
-}
-
-interface NodeOutputProps {
- state: NodeState;
-}
-
-const NodeContainer = styled.div`
- margin-bottom: 20px;
- background: ${p => p.theme.colors.spotBackground[0]};
- border-radius: 5px;
- padding: 10px 15px 10px;
-`;
-
-const NodeTitle = styled.div`
- font-size: 16px;
- font-weight: bold;
- margin-bottom: 10px;
-`;
-
-const NodeContent = styled.div`
- background: #020308;
- margin-bottom: 10px;
- min-width: 500px;
- border-radius: 5px;
- padding: 1px 20px;
- color: white;
-`;
-
-const LoadingContainer = styled.div`
- display: flex;
- justify-content: center;
- margin: 30px 0 20px;
-`;
-
-const Output = styled.pre`
- overflow-x: auto;
-`;
-
-function NodeOutput(props: NodeOutputProps) {
- return (
-
- {props.state.nodeId}
-
- {props.state.status === RunActionStatus.Connecting && (
-
-
-
- )}
-
- {props.state.stdout !== undefined &&
- (props.state.stdout === '' ? (
- 'Empty output.'
- ) : (
-
-
-
- ))}
-
- );
-}
diff --git a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/common.ts b/web/packages/teleport/src/Assist/Chat/ChatItem/Action/common.ts
deleted file mode 100644
index 6e526bafa2022..0000000000000
--- a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/common.ts
+++ /dev/null
@@ -1,55 +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';
-
-import { Type } from 'teleport/Assist/services/messages';
-
-export const Container = styled.div`
- border: 1px solid ${p => p.theme.colors.spotBackground[2]};
- border-radius: 10px;
- padding: 15px 20px;
- position: relative;
- width: 100%;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
-`;
-
-export const Title = styled.div`
- font-size: 14px;
- margin-bottom: 10px;
-`;
-
-export const Items = styled.div`
- display: flex;
- flex-wrap: wrap;
- margin-top: -10px;
- align-items: center;
-
- > * {
- margin-top: 10px;
- }
-`;
-
-export function getTextForType(type: Type) {
- switch (type) {
- case Type.ExecuteRemoteCommand:
- return 'Connect to';
- case Type.Message:
- return '';
- }
-}
diff --git a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/index.ts b/web/packages/teleport/src/Assist/Chat/ChatItem/Action/index.ts
deleted file mode 100644
index b75b118609679..0000000000000
--- a/web/packages/teleport/src/Assist/Chat/ChatItem/Action/index.ts
+++ /dev/null
@@ -1,18 +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 { Action } from './Action';
-export { Actions } from './Actions';
diff --git a/web/packages/teleport/src/Assist/Chat/ChatItem/ChatItem.tsx b/web/packages/teleport/src/Assist/Chat/ChatItem/ChatItem.tsx
deleted file mode 100644
index f58f7daf8fcb7..0000000000000
--- a/web/packages/teleport/src/Assist/Chat/ChatItem/ChatItem.tsx
+++ /dev/null
@@ -1,304 +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 from 'react';
-import styled, { keyframes } from 'styled-components';
-
-import DOMPurify from 'dompurify';
-import highlight from 'highlight.js';
-
-import { marked } from 'marked';
-
-import teleport from 'design/assets/images/icons/teleport.png';
-
-import { useTeleport } from 'teleport';
-
-import { getBorderRadius } from 'teleport/Assist/Chat/ChatItem/utils';
-
-import { Author, Message, Type } from '../../services/messages';
-
-import { Timestamp } from '../Timestamp';
-
-import { codeCSS } from './styles/code';
-import { markdownCSS } from './styles/markdown';
-
-import { Actions } from './Action';
-
-interface ChatItemProps {
- message: Message;
- isNew: boolean;
- scrollTextarea: () => void;
- hideAvatar: boolean;
- isFirstFromUser: boolean;
- isLastFromUser: boolean;
-}
-
-const appear = keyframes`
- to {
- transform: translate3d(0, 0, 0);
- opacity: 1;
- }
-`;
-
-const Content = styled.div`
- padding: 20px 25px 4px;
- box-shadow: 0 6px 12px -2px rgba(50, 50, 93, 0.05),
- 0 3px 7px -3px rgba(0, 0, 0, 0.1);
- max-width: 90%;
-`;
-
-const Container = styled.div<{
- teleport?: boolean;
- isLast: boolean;
- isNew: boolean;
-}>`
- display: flex;
- flex-direction: column;
- align-items: ${p => (p.teleport ? 'flex-start' : 'flex-end')};
- justify-content: ${p => (p.teleport ? '' : 'flex-end')};
- margin: 0 30px ${p => (p.hasSpacing ? '25px' : '15px')} 30px;
- position: relative;
- animation: ${appear} 0.6s linear forwards;
- transform: ${p => (p.isNew ? 'translate3d(0, 30px, 0)' : 'none')};
- opacity: ${p => (p.isNew ? 0 : 1)};
- font-size: 14px;
-
- ${Content} {
- background: ${p =>
- p.teleport
- ? p.theme.colors.levels.popout
- : p.theme.colors.buttons.primary.default};
- color: ${p =>
- p.teleport
- ? p.theme.colors.text.main
- : p.theme.colors.buttons.primary.text};
- border-radius: ${p =>
- getBorderRadius(p.teleport, p.isFirstFromUser, p.isLastFromUser)};
- }
-`;
-
-const ChatItemAvatarUser = styled.div`
- width: 30px;
- height: 30px;
- border-radius: 10px;
- overflow: hidden;
- font-size: 14px;
- font-weight: bold;
- display: flex;
- align-items: center;
- justify-content: center;
- background-size: cover;
- margin-right: 10px;
- background: ${props => props.theme.colors.brand};
- color: ${p => p.theme.colors.buttons.primary.text};
-`;
-
-const ChatItemAvatarImage = styled.div<{ backgroundImage: string }>`
- background: url(${p => p.backgroundImage}) no-repeat;
- width: 22px;
- height: 22px;
- overflow: hidden;
- background-size: cover;
-`;
-
-const AvatarContainer = styled.div`
- display: flex;
- align-items: center;
- color: ${props => props.theme.colors.text.slightlyMuted};
- margin-top: 20px;
-
- strong {
- display: block;
- margin-right: 10px;
- color: ${props => props.theme.colors.text.main};
- }
-`;
-
-const UserAvatarContainer = styled(AvatarContainer)`
- right: 0;
-`;
-
-const TeleportAvatarContainer = styled(AvatarContainer)`
- left: 0;
-`;
-
-const ChatItemAvatarTeleport = styled.div`
- background: ${props => props.theme.colors.brand};
- color: ${p => p.theme.colors.buttons.primary.text};
- padding: 4px;
- border-radius: 10px;
- left: 0;
- right: auto;
- margin-right: 10px;
-`;
-
-const ChatItemContent = styled.div`
- font-size: 15px;
- width: 100%;
- position: relative;
-
- ${markdownCSS}
- ${codeCSS}
-`;
-
-// TODO(jakule || ryan): Remove duplicated styles.
-const CommandOutput = styled.div`
- margin-bottom: 15px;
- min-width: 100%;
-`;
-
-const MachineName = styled.div`
- margin-bottom: 5px;
- font-size: 14px;
-`;
-
-const Output = styled.div`
- white-space: pre-wrap;
- background: #020308;
- color: white;
- border-radius: 5px;
- min-width: 500px;
- padding: 5px 10px;
- font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier,
- monospace;
-`;
-
-const ErrorMessage = styled.div`
- color: ${p => p.theme.colors.error.main};
- font-size: 15px;
- font-weight: 500;
-`;
-
-marked.setOptions({
- renderer: new marked.Renderer(),
- highlight: function (code, lang) {
- const language = highlight.getLanguage(lang) ? lang : 'plaintext';
-
- return highlight.highlight(code, { language }).value;
- },
- langPrefix: 'hljs language-',
- pedantic: false,
- gfm: true,
- breaks: true,
- sanitize: false,
- smartLists: true,
- smartypants: false,
- xhtml: false,
-});
-
-export function ChatItem(props: ChatItemProps) {
- const ctx = useTeleport();
-
- let content;
-
- switch (props.message.content.type) {
- case Type.Message:
- content = (
-
- );
-
- break;
-
- case Type.ExecuteRemoteCommand:
- content = (
-
- );
-
- break;
- case Type.ExecuteCommandOutput:
- return (
-
-
-
-
- Command ran on node{' '}
- {props.message.content.nodeId}
-
- {props.message.content.errorMsg ? (
- {props.message.content.errorMsg}
- ) : props.message.content.payload === '' ? (
-
Empty output.
- ) : (
-
- )}
-
-
-
- );
- }
-
- let avatar = (
-
-
-
-
-
- Teleport
-
-
-
- );
-
- if (props.message.author === Author.User) {
- avatar = (
-
-
- {ctx.storeUser.state.username.slice(0, 1).toUpperCase()}
-
-
- You
-
-
-
- );
- }
-
- return (
-
- {content}
-
- {!props.hideAvatar && avatar}
-
- );
-}
diff --git a/web/packages/teleport/src/Assist/Chat/ChatItem/styles/code.ts b/web/packages/teleport/src/Assist/Chat/ChatItem/styles/code.ts
deleted file mode 100644
index 3a657f99fd427..0000000000000
--- a/web/packages/teleport/src/Assist/Chat/ChatItem/styles/code.ts
+++ /dev/null
@@ -1,92 +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 { css } from 'styled-components';
-
-export const codeCSS = css`
- pre .hljs-comment,
- pre .hljs-title {
- color: #7285b7;
- }
-
- pre .hljs-variable,
- pre .hljs-attribute,
- pre .hljs-tag,
- pre .hljs-regexp,
- pre .hljs-ruby .constant,
- pre .hljs-xml .tag .title,
- pre .hljs-xml .pi,
- pre .hljs-xml .doctype,
- pre .hljs-html .doctype,
- pre .hljs-css .id,
- pre .hljs-css .class,
- pre .hljs-css .pseudo {
- color: #ff9da4;
- }
-
- pre .hljs-number,
- pre .hljs-preprocessor,
- pre .hljs-built_in,
- pre .hljs-literal,
- pre .hljs-params,
- pre .hljs-constant {
- color: #ffc58f;
- }
-
- pre .hljs-class,
- pre .hljs-ruby .class .title,
- pre .hljs-css .rules .attribute {
- color: #ffeead;
- }
-
- pre .hljs-string,
- pre .hljs-value,
- pre .hljs-inheritance,
- pre .hljs-header,
- pre .hljs-ruby .symbol,
- pre .hljs-xml .cdata {
- color: #d1f1a9;
- }
-
- pre .hljs-css .hexcolor {
- color: #99ffff;
- }
-
- pre .hljs-function,
- pre .hljs-python .decorator,
- pre .hljs-python .title,
- pre .hljs-ruby .function .title,
- pre .hljs-ruby .title .keyword,
- pre .hljs-perl .sub,
- pre .hljs-javascript .title,
- pre .hljs-coffeescript .title {
- color: #bbdaff;
- }
-
- pre .hljs-keyword,
- pre .hljs-javascript .function {
- color: #ebbbff;
- }
-
- pre code {
- display: block;
- color: white;
- font-family: Menlo, Monaco, Consolas, monospace;
- font-size: 14px;
- line-height: 26px;
- border-radius: 10px;
- }
-`;
diff --git a/web/packages/teleport/src/Assist/Chat/index.ts b/web/packages/teleport/src/Assist/Chat/index.ts
deleted file mode 100644
index c1c6b784089a7..0000000000000
--- a/web/packages/teleport/src/Assist/Chat/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 { Chat } from './Chat';
diff --git a/web/packages/teleport/src/Assist/Conversation/Avatar.tsx b/web/packages/teleport/src/Assist/Conversation/Avatar.tsx
new file mode 100644
index 0000000000000..02a4f9bed5808
--- /dev/null
+++ b/web/packages/teleport/src/Assist/Conversation/Avatar.tsx
@@ -0,0 +1,72 @@
+/**
+ * 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';
+
+import teleport from 'design/assets/images/icons/teleport.png';
+
+import { useTeleport } from 'teleport';
+
+export const TeleportImage = styled.div<{ backgroundImage: string }>`
+ background: url(${teleport}) no-repeat;
+ width: 22px;
+ height: 22px;
+ overflow: hidden;
+ background-size: cover;
+`;
+
+export const TeleportAvatarContainer = styled.div`
+ background: ${props => props.theme.colors.brand};
+ padding: 4px;
+ border-radius: 10px;
+ left: 0;
+ right: auto;
+ margin-right: 10px;
+`;
+
+export function TeleportAvatar() {
+ return (
+
+
+
+ );
+}
+
+const UserAvatarContainer = styled.div`
+ width: 30px;
+ height: 30px;
+ border-radius: 10px;
+ overflow: hidden;
+ font-size: 14px;
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-size: cover;
+ margin-right: 10px;
+ background: ${props => props.theme.colors.brand};
+ color: ${p => p.theme.colors.buttons.primary.text};
+`;
+
+export function UserAvatar() {
+ const ctx = useTeleport();
+
+ return (
+
+ {ctx.storeUser.state.username.slice(0, 1).toUpperCase()}
+
+ );
+}
diff --git a/web/packages/teleport/src/Assist/Conversation/CommandResultEntry.tsx b/web/packages/teleport/src/Assist/Conversation/CommandResultEntry.tsx
new file mode 100644
index 0000000000000..196d01fffdf1b
--- /dev/null
+++ b/web/packages/teleport/src/Assist/Conversation/CommandResultEntry.tsx
@@ -0,0 +1,100 @@
+/**
+ * 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, { keyframes } from 'styled-components';
+
+interface CommandResultEntryProps {
+ nodeId: string;
+ nodeName: string;
+ output: string;
+ finished: boolean;
+ errorMessage?: string;
+}
+
+const Container = styled.div`
+ border-radius: 10px;
+ font-size: 18px;
+ position: relative;
+`;
+
+const Title = styled.div`
+ font-size: 15px;
+ font-weight: 600;
+ padding: 10px 15px;
+`;
+
+const Output = styled.pre.attrs({ 'data-scrollbar': 'default' })`
+ background: #161b22;
+ color: white;
+ padding: 10px 15px;
+ margin: 0;
+ overflow-x: auto;
+ font-family: ${p => p.theme.fonts.mono};
+ font-size: 13px;
+`;
+
+const Header = styled.div`
+ display: flex;
+ justify-content: space-between;
+ padding-right: 20px;
+`;
+
+const spin = keyframes`
+ to {
+ transform: rotate(360deg);
+ }
+`;
+
+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: relative;
+ top: 4px;
+`;
+
+export function CommandResultEntry(props: CommandResultEntryProps) {
+ return (
+
+
+ Command output for {props.nodeName || props.nodeId}
+ {!props.finished && (
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/web/packages/teleport/src/Assist/Conversation/Conversation.tsx b/web/packages/teleport/src/Assist/Conversation/Conversation.tsx
new file mode 100644
index 0000000000000..4790a7186005b
--- /dev/null
+++ b/web/packages/teleport/src/Assist/Conversation/Conversation.tsx
@@ -0,0 +1,114 @@
+/**
+ * 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 { useAssist } from 'teleport/Assist/context/AssistContext';
+import { Message } from 'teleport/Assist/Conversation/Message';
+import {
+ TypingContainer,
+ TypingDot,
+} from 'teleport/Assist/Conversation/Typing';
+import AuthnDialog from 'teleport/components/AuthnDialog';
+import { makeWebauthnAssertionResponse } from 'teleport/services/auth';
+
+const Container = styled.ul`
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ flex: 1;
+ position: relative;
+`;
+
+const Loading = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: calc(100% - 10px);
+ width: inherit;
+`;
+
+export function Conversation() {
+ const {
+ cancelMfaChallenge,
+ messages,
+ mfa,
+ selectedConversationMessages,
+ sendMfaChallenge,
+ } = useAssist();
+
+ const [mfaErrorMessage, setMfaErrorMessage] = useState(null);
+
+ if (messages.loading) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ if (!selectedConversationMessages) {
+ return null;
+ }
+
+ const items = selectedConversationMessages.map((message, index) => (
+
+ ));
+
+ async function mfaAuthenticate() {
+ if (!window.PublicKeyCredential) {
+ const errorText =
+ 'This browser does not support WebAuthn required for hardware tokens, \
+ please try the latest version of Chrome, Firefox or Safari.';
+
+ setMfaErrorMessage(errorText);
+
+ return;
+ }
+
+ try {
+ const res = await navigator.credentials.get({ publicKey: mfa.publicKey });
+ const credential = makeWebauthnAssertionResponse(res);
+
+ sendMfaChallenge(credential);
+ } catch (err) {
+ setMfaErrorMessage(err.message);
+ }
+ }
+
+ return (
+ <>
+ {mfa.prompt && (
+
+ )}
+
+ {items}
+ >
+ );
+}
diff --git a/web/packages/teleport/src/Assist/Conversation/EntryContainer.tsx b/web/packages/teleport/src/Assist/Conversation/EntryContainer.tsx
new file mode 100644
index 0000000000000..b0ffdd359f7c4
--- /dev/null
+++ b/web/packages/teleport/src/Assist/Conversation/EntryContainer.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, { PropsWithChildren } from 'react';
+import styled, { keyframes } from 'styled-components';
+
+import { Author } from 'teleport/Assist/types';
+
+interface EntryContainerProps {
+ author: Author;
+ index: number;
+ length: number;
+ streaming: boolean;
+ lastMessage: boolean;
+ hideOverflow?: boolean;
+}
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: ${p =>
+ p.author === Author.Teleport ? 'flex-start' : 'flex-end'};
+ justify-content: ${p => (p.author === Author.Teleport ? '' : 'flex-end')};
+ position: relative;
+ font-size: 14px;
+ margin-bottom: 5px;
+
+ --content-overflow: ${p => (p.hideOverflow ? 'hidden' : 'visible')};
+ --content-background: ${p =>
+ p.author === Author.Teleport
+ ? p.theme.colors.levels.popout
+ : p.theme.colors.buttons.primary.default};
+ --content-color: ${p =>
+ p.author === Author.Teleport
+ ? p.theme.colors.text.main
+ : p.theme.colors.buttons.primary.text};
+ --content-border-radius: ${p =>
+ getBorderRadius(p.author === Author.Teleport, p.index, p.length)};
+`;
+
+const blink = keyframes`
+ to {
+ visibility: hidden;
+ }
+`;
+
+const Content = styled.div`
+ background: var(--content-background);
+ color: var(--content-color);
+ border-radius: var(--content-border-radius);
+ box-shadow: 0 6px 12px -2px rgba(50, 50, 93, 0.05),
+ 0 3px 7px -3px rgba(0, 0, 0, 0.1);
+ max-width: 90%;
+ border: 1px solid ${p => p.theme.colors.spotBackground[1]};
+ overflow: var(--content-overflow);
+
+ &.streaming {
+ > div > :not(ol):not(ul):not(pre):last-child:after,
+ > div > ol:last-child li:last-child:after,
+ > div > pre:last-child code:after,
+ > div > ul:last-child li:last-child:after {
+ animation: ${blink} 1s steps(5, start) infinite;
+ content: 'â–‹';
+ margin-left: 0.25rem;
+ vertical-align: baseline;
+ opacity: 0.8;
+ }
+ }
+`;
+
+export function EntryContainer(props: PropsWithChildren) {
+ const authorIsTeleport = props.author === Author.Teleport;
+ const streaming = props.streaming && props.lastMessage && authorIsTeleport;
+
+ return (
+
+
+ {props.children}
+
+
+ );
+}
+
+function getBorderRadius(isTeleport: boolean, index: number, length: number) {
+ const isLast = index === length - 1;
+ const isFirst = index === 0;
+
+ if (isTeleport) {
+ return `${isFirst ? '14px' : '5px'} 14px 14px ${isLast ? '14px' : '5px'}`;
+ }
+
+ return `14px ${isFirst ? '14px' : '5px'} ${isLast ? '14px' : '5px'} 14px`;
+}
diff --git a/web/packages/teleport/src/Assist/Conversation/ExecuteRemoteCommandEntry.tsx b/web/packages/teleport/src/Assist/Conversation/ExecuteRemoteCommandEntry.tsx
new file mode 100644
index 0000000000000..a55c9cd9e8465
--- /dev/null
+++ b/web/packages/teleport/src/Assist/Conversation/ExecuteRemoteCommandEntry.tsx
@@ -0,0 +1,212 @@
+/**
+ * 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, { useEffect, useState } from 'react';
+import styled from 'styled-components';
+import Flex from 'design/Flex';
+
+import Select from 'shared/components/Select';
+import { ButtonPrimary, ButtonSecondary } from 'design';
+
+import { useAssist } from 'teleport/Assist/context/AssistContext';
+import { getLoginsForQuery } from 'teleport/Assist/service';
+import useStickyClusterId from 'teleport/useStickyClusterId';
+
+interface ExecuteRemoteCommandEntryProps {
+ command: string;
+ query: string;
+ disabled: boolean;
+}
+
+const Container = styled.div`
+ padding: 15px 15px 15px 17px;
+ width: var(--command-input-width);
+`;
+
+const StyledInput = styled.input<{ hasError: boolean }>`
+ border: 1px solid
+ ${p =>
+ p.hasError
+ ? p.theme.colors.error.main
+ : p.theme.colors.spotBackground[0]};
+ padding: 12px 15px;
+ border-radius: 5px;
+ font-family: ${p => p.theme.fonts.mono};
+ background: ${p => p.theme.colors.levels.surface};
+
+ &:disabled {
+ background: ${p => p.theme.colors.spotBackground[0]};
+ }
+
+ &:active:not(:disabled),
+ &:focus:not(:disabled) {
+ outline: none;
+ border-color: ${p => p.theme.colors.text.slightlyMuted};
+ }
+`;
+
+const QueryInput = styled(StyledInput)`
+ flex: 1;
+`;
+
+const ErrorMessage = styled.div`
+ color: ${p => p.theme.colors.error.main};
+`;
+
+const CommandInput = styled(StyledInput)`
+ width: calc(100% - 32px);
+`;
+
+const Spacer = styled.div`
+ padding: 0 10px;
+`;
+
+const InfoText = styled.span`
+ display: block;
+ font-size: 14px;
+ font-weight: 600;
+ margin: 5px 0;
+`;
+
+export function ExecuteRemoteCommandEntry(
+ props: ExecuteRemoteCommandEntryProps
+) {
+ const { executeCommand } = useAssist();
+
+ const [hasRan, setHasRan] = useState(false);
+ const [command, setCommand] = useState(props.command);
+ const [query, setQuery] = useState(props.query);
+ const [selectedLogin, setSelectedLogin] = useState('');
+ const [availableLogins, setAvailableLogins] = useState([]);
+ const [errorMessage, setErrorMessage] = useState(null);
+
+ const { clusterId } = useStickyClusterId();
+
+ async function updateAvailableLogins() {
+ if (props.disabled) {
+ return;
+ }
+
+ setErrorMessage(null);
+
+ try {
+ const logins = await getLoginsForQuery(query, clusterId);
+
+ if (!selectedLogin || !logins.includes(selectedLogin)) {
+ setSelectedLogin(logins[0]);
+ }
+
+ setAvailableLogins(logins);
+ } catch (err) {
+ if (err instanceof Error) {
+ if (err.message.includes('failed to parse predicate expression')) {
+ setErrorMessage('Invalid query');
+
+ return;
+ }
+
+ setErrorMessage(err.message);
+
+ return;
+ }
+
+ setErrorMessage('Something went wrong');
+ }
+ }
+
+ useEffect(() => {
+ updateAvailableLogins();
+ }, []);
+
+ function handleQueryChange(event: React.ChangeEvent) {
+ setQuery(event.target.value);
+ }
+
+ function handleCommandChange(event: React.ChangeEvent) {
+ setCommand(event.target.value);
+ }
+
+ function handleReset() {
+ setQuery(props.query);
+ setCommand(props.command);
+ updateAvailableLogins();
+ }
+
+ const disabled =
+ errorMessage !== null || !selectedLogin || !command || props.disabled;
+
+ function handleRun() {
+ if (disabled) {
+ return;
+ }
+
+ setHasRan(true);
+ executeCommand(selectedLogin, command, query);
+ }
+
+ return (
+
+
+ Teleport would like to connect to
+
+
+
+
+
+ as
+
+
+
+ {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}
+
+
+
+ );
+}
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 && (
+
+
+
+ )}
+