From b8527bb97f9830be56d44fa9d6416f6b3398d165 Mon Sep 17 00:00:00 2001 From: Eileen Li Date: Tue, 30 Jul 2024 15:36:15 -0700 Subject: [PATCH 1/7] fix: update message handling --- .../elementRenderer/elementRenderer.cy.tsx | 73 +++++-- .../elementRenderer/elementRenderer.tsx | 25 +-- .../messageCanvas/messageCanvas.cy.tsx | 4 +- .../messageCanvas/messageCanvas.stories.tsx | 28 +-- .../messageSpace/messageSpace.stories.tsx | 18 +- src/components/messageSpace/messageSpace.tsx | 97 ++++++--- src/components/promptBuilder/mockWebSocket.ts | 7 +- .../promptBuilder/promptBuilder.cy.tsx | 190 +++++++++--------- .../promptBuilder/promptBuilder.tsx | 7 +- src/components/types.ts | 2 +- 10 files changed, 269 insertions(+), 182 deletions(-) diff --git a/src/components/elementRenderer/elementRenderer.cy.tsx b/src/components/elementRenderer/elementRenderer.cy.tsx index cb833437..81d90b9e 100644 --- a/src/components/elementRenderer/elementRenderer.cy.tsx +++ b/src/components/elementRenderer/elementRenderer.cy.tsx @@ -4,13 +4,14 @@ import { supportedViewports, testUser, } from '../../../cypress/support/variables' -import { YoutubeVideo } from '..' +import { StreamingText, YoutubeVideo } from '..' import Text from '../text/text' import ElementRenderer from './elementRenderer' const supportedElements = { text: Text, video: YoutubeVideo, + streamingText: StreamingText, } const sampleMessage = { @@ -38,11 +39,13 @@ describe('ElementRenderer', () => { cy.viewport(viewport) cy.mount( @@ -50,11 +53,13 @@ describe('ElementRenderer', () => { cy.get('p').should('contain.text', 'Test Text') cy.mount( @@ -71,6 +76,40 @@ describe('ElementRenderer', () => { }) }) + it(`renders the original message and its update messages correctly on ${viewport} screen`, () => { + const mockWsClient = { + send: cy.stub(), + close: cy.stub(), + reconnect: cy.stub(), + } + + cy.viewport(viewport) + cy.mount( + + ) + cy.get('p').should('contain.text', 'Test Text') + }) + it(`renders a message for an unsupported format on ${viewport} screen`, () => { const mockWsClient = { send: cy.stub(), @@ -81,11 +120,13 @@ describe('ElementRenderer', () => { cy.viewport(viewport) cy.mount( diff --git a/src/components/elementRenderer/elementRenderer.tsx b/src/components/elementRenderer/elementRenderer.tsx index 11f12e40..a6dc42f9 100644 --- a/src/components/elementRenderer/elementRenderer.tsx +++ b/src/components/elementRenderer/elementRenderer.tsx @@ -1,39 +1,34 @@ import Typography from '@mui/material/Typography' import React from 'react' -import type { - ComponentMap, - Sender, - ThreadableMessage, - WebSocketClient, -} from '../types' +import type { ComponentMap, Message, Sender, WebSocketClient } from '../types' interface ElementRendererProps { sender: Sender ws: WebSocketClient - message: ThreadableMessage + messages: Message[] supportedElements: ComponentMap } const ElementRenderer = (props: ElementRendererProps) => { - const MaybeElement = props.supportedElements[props.message.format] + const rootMessage = props.messages[0] + const updateMessages = props.messages.slice(1) + const MaybeElement = props.supportedElements[rootMessage.format] return ( <> {MaybeElement ? ( React.createElement(MaybeElement, { sender: props.sender, ws: props.ws, - messageId: props.message.id, - conversationId: props.message.conversationId, - ...props.message.data, - ...(props.message.threadMessagesData && { - updatedData: props.message.threadMessagesData, - }), + messageId: rootMessage.id, + conversationId: rootMessage.conversationId, + ...rootMessage.data, + updatedData: updateMessages.map((message) => message.data), }) ) : ( - Unsupported element format: {props.message.format} + Unsupported element format: {rootMessage.format} )} diff --git a/src/components/messageCanvas/messageCanvas.cy.tsx b/src/components/messageCanvas/messageCanvas.cy.tsx index 001aad3e..89cdf349 100644 --- a/src/components/messageCanvas/messageCanvas.cy.tsx +++ b/src/components/messageCanvas/messageCanvas.cy.tsx @@ -6,7 +6,7 @@ import { testUser, } from '../../../cypress/support/variables' import Icon from '../icon/icon' -import type { ThreadableMessage } from '../types' +import type { Message } from '../types' import CopyText from './actions/copy/copyText' import MessageCanvas from './messageCanvas' @@ -43,7 +43,7 @@ describe('MessageCanvas', () => { cy.mount( { + getActionsComponent={(message: Message) => { const copyButton = message.format === 'text' && ( ) diff --git a/src/components/messageCanvas/messageCanvas.stories.tsx b/src/components/messageCanvas/messageCanvas.stories.tsx index 5d816a48..079f28a0 100644 --- a/src/components/messageCanvas/messageCanvas.stories.tsx +++ b/src/components/messageCanvas/messageCanvas.stories.tsx @@ -1,9 +1,11 @@ import Typography from '@mui/material/Typography' import React from 'react' -import { ElementRenderer, MarkedMarkdown, type ThreadableMessage } from '..' +import ElementRenderer from '../elementRenderer' import Icon from '../icon/icon' +import MarkedMarkdown from '../markdown' import Text from '../text/text' +import type { Message } from '../types' import CopyText from './actions/copy/copyText' import TextToSpeech from './actions/textToSpeech/textToSpeech' import MessageCanvas from './messageCanvas' @@ -111,7 +113,7 @@ const elementRendererString = `` -const profileString = `(message: ThreadableMessage) => { +const profileString = `(message: Message) => { <> {getProfileIcon(message)} @@ -120,7 +122,7 @@ const profileString = `(message: ThreadableMessage) => { }` -function getProfileIcon(message: ThreadableMessage) { +function getProfileIcon(message: Message) { if (message.sender.name?.includes('agent')) { return } else { @@ -128,7 +130,7 @@ function getProfileIcon(message: ThreadableMessage) { } } -function getProfileName(message: ThreadableMessage) { +function getProfileName(message: Message) { return ( {message.sender.name} @@ -136,7 +138,7 @@ function getProfileName(message: ThreadableMessage) { ) } -function getProfileIconAndName(message: ThreadableMessage) { +function getProfileIconAndName(message: Message) { return ( <> {getProfileIcon(message)} @@ -149,7 +151,7 @@ export const WithProfileIcon = { args: { children: ( @@ -175,7 +177,7 @@ export const NoIcon = { args: { children: ( @@ -201,14 +203,14 @@ export const WithCopyIcon = { args: { children: ( ), message: messageFromHuman, getProfileComponent: getProfileIconAndName, - getActionsComponent: (message: ThreadableMessage) => { + getActionsComponent: (message: Message) => { const copyButton = message.format === 'text' && ( ) @@ -222,7 +224,7 @@ export const WithCopyIcon = { source: { code: ` { + getActionsComponent={(message: Message) => { const copyButton = message.format === 'text' && if (copyButton) { return <>{copyButton} @@ -241,14 +243,14 @@ export const WithTextToSpeech = { args: { children: ( ), message: markdownMessage, getProfileComponent: getProfileIconAndName, - getActionsComponent: (message: ThreadableMessage) => { + getActionsComponent: (message: Message) => { return ( <> @@ -265,7 +267,7 @@ export const WithTextToSpeech = { source: { code: ` { + getActionsComponent={(message: Message) => { const copyButton = message.format === 'text' && if (copyButton) { return <>{copyButton} diff --git a/src/components/messageSpace/messageSpace.stories.tsx b/src/components/messageSpace/messageSpace.stories.tsx index 857794b7..09eeedb9 100644 --- a/src/components/messageSpace/messageSpace.stories.tsx +++ b/src/components/messageSpace/messageSpace.stories.tsx @@ -197,7 +197,7 @@ const tableData = [ ] const chartColors = ['#648FFF', '#785EF0', '#DC267F', '#FE6100', '#FFB000'] - +const streamingMarkdownRootMessageId = getUUID() export const Default = { args: { ws: { send: () => {} }, @@ -214,11 +214,21 @@ export const Default = { }, { ...agentMessageData, - id: getUUID(), + id: streamingMarkdownRootMessageId, timestamp: '2024-01-02T00:01:00.000Z', - format: 'markdown', + format: 'streamingMarkdown', + data: { + text: '# Title\n\n---\n\n ## Subtitle', + }, + }, + { + ...agentMessageData, + id: getUUID(), + timestamp: '2024-01-02T00:02:01.000Z', + format: 'updateStreamingMarkdown', + threadId: streamingMarkdownRootMessageId, data: { - text: '# Title\n\n---\n\n ## Subtitle\n\nThis is a paragraph. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.\n\n- This is an **inline notation**\n- This is a *inline notation*.\n- This is a _inline notation_.\n- This is a __inline notation__.\n- This is a ~~inline notation~~.\n\n```\nconst string = "Hello World"\nconst number = 123\n```\n\n> This is a blockquote.\n\n1. Item 1\n2. Item 2\n3. Item 3\n\n| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Item 1 | Item 2 | Item 3 |', + text: '\n\nThis is a paragraph. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.\n\n- This is an **inline notation**\n- This is a *inline notation*.\n- This is a _inline notation_.\n- This is a __inline notation__.\n- This is a ~~inline notation~~.\n\n```\nconst string = "Hello World"\nconst number = 123\n```\n\n> This is a blockquote.\n\n1. Item 1\n2. Item 2\n3. Item 3\n\n| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Item 1 | Item 2 | Item 3 |', }, }, { diff --git a/src/components/messageSpace/messageSpace.tsx b/src/components/messageSpace/messageSpace.tsx index 767ca6d5..71dc7117 100644 --- a/src/components/messageSpace/messageSpace.tsx +++ b/src/components/messageSpace/messageSpace.tsx @@ -9,12 +9,7 @@ import Icon from '../icon/icon' import MessageCanvas, { type MessageContainerProps, } from '../messageCanvas/messageCanvas' -import type { - ComponentMap, - Sender, - ThreadableMessage, - WebSocketClient, -} from '../types' +import type { ComponentMap, Message, Sender, WebSocketClient } from '../types' export interface MessageSpaceProps extends MessageContainerProps { /** WebSocket connection to send and receive messages to and from a backend. This can be useful for component interactions, for example, to send filter conditions, user location, etc. */ @@ -23,7 +18,7 @@ export interface MessageSpaceProps extends MessageContainerProps { sender: Sender /** A component map contains message formats as keys and their corresponding React components as values. */ supportedElements: ComponentMap - messages?: ThreadableMessage[] + messages?: Message[] /** Text label for scroll down button. Default value is 'scroll down'. */ scrollDownLabel?: string } @@ -36,6 +31,23 @@ function usePrevious(value: number) { return ref.current } +function getCombinedMessages( + messages: { [key: string]: Message[] }, + message: Message +) { + const key = message.format.includes('update') ? message.threadId : message.id + if (key) { + const existingMessages = messages[key] || [] + const newMessages = { + ...messages, + [key]: existingMessages.concat(message), + } + return newMessages + } else { + return messages + } +} + /** The `MessageSpace` component uses `MessageCanvas` and `ElementRenderer` to render a list of messages. It serves as a container for individual message items, each encapsulated within a `MessageCanvas` for consistent styling and layout. @@ -51,8 +63,10 @@ export default function MessageSpace(props: MessageSpaceProps) { const [isScrolledToBottom, setIsScrolledToBottom] = useState(true) const [isScrollButtonHidden, setIsScrollButtonHidden] = useState(true) const [areVideosLoaded, setAreVideosLoaded] = useState(false) - - const currentMessagesLength = props.messages?.length || 0 + const [chatMessages, setChatMessages] = useState<{ + [messageId: string]: Message[] + }>({}) + const currentMessagesLength = Object.keys(chatMessages).length const previousMessagesLength = usePrevious(currentMessagesLength) const hideScrollButtonDuration = 2000 @@ -156,7 +170,30 @@ export default function MessageSpace(props: MessageSpaceProps) { hideScrollButton() scrollToLastMessage() } - }, [isScrolledToBottom, props.messages?.length]) + }, [isScrolledToBottom, Object.keys(chatMessages).length]) + + useEffect(() => { + let messageDict: { [messageId: string]: Message[] } = {} + + props.messages?.forEach((message) => { + const newMessageDict = getCombinedMessages(messageDict, message) + messageDict = newMessageDict + }) + + setChatMessages(messageDict) + }, [props.messages]) + + function handleIncomingMessage(message: Message) { + setChatMessages((prevMessages) => + getCombinedMessages(prevMessages, message) + ) + } + + useEffect(() => { + if (props.ws.onReceive) { + props.ws.onReceive(handleIncomingMessage) + } + }, []) return ( - {props.messages && - props.messages.length > 0 && - props.messages.map((message, index) => { - return ( - - - - ) - })} + {Object.keys(chatMessages).map((key, index) => { + const messages = chatMessages[key] + const lastestMessage = messages[messages.length - 1] + return ( + + + + ) + })} {!isScrolledToBottom && !isScrollButtonHidden && ( void) => { - ws.onmessage = handler + onReceive: (handler: (message: Message) => void) => { + ws.onmessage = (event) => { + const receivedMessage = JSON.parse(event.data) + handler(receivedMessage) + } }, } } diff --git a/src/components/promptBuilder/promptBuilder.cy.tsx b/src/components/promptBuilder/promptBuilder.cy.tsx index ca178507..722691f6 100644 --- a/src/components/promptBuilder/promptBuilder.cy.tsx +++ b/src/components/promptBuilder/promptBuilder.cy.tsx @@ -1,100 +1,100 @@ -import { Server } from 'mock-socket' +// import { Server } from 'mock-socket' -import Question from '../question/question' -import { getMockWebSocketClient, sendMessageToClient } from './mockWebSocket' -import PromptBuilder from './promptBuilder' +// import Question from '../question/question' +// import { getMockWebSocketClient, sendMessageToClient } from './mockWebSocket' +// import PromptBuilder from './promptBuilder' -const webSocketUrl = 'ws://localhost:8081' -const server = new Server(webSocketUrl) +// const webSocketUrl = 'ws://localhost:8081' +// const server = new Server(webSocketUrl) -server.on('connection', (socket) => { - sendMessageToClient(socket, 'question', { - title: 'What is your main goal?', - options: [ - 'grow my business', - 'sell internationally', - 'hire or train employees', - ], - }) +// server.on('connection', (socket) => { +// sendMessageToClient(socket, 'question', { +// title: 'What is your main goal?', +// options: [ +// 'grow my business', +// 'sell internationally', +// 'hire or train employees', +// ], +// }) - socket.on('message', () => { - sendMessageToClient(socket, 'promptBuilder', { - isLastQuestion: true, - }) - }) -}) +// socket.on('message', () => { +// sendMessageToClient(socket, 'promptBuilder', { +// isLastQuestion: true, +// }) +// }) +// }) -describe('PromptBuilder Component', () => { - const promptBuilder = '[data-cy=prompt-builder]' - const componentTitle = '[data-cy=component-title]' - const quitButton = '[data-cy=quit-button]' - const quitDialogTitle = '[data-cy=quit-dialog-title]' - const confirmQuitButton = '[data-cy=confirm-quit-button]' - const continueBuildButton = '[data-cy=continue-build-button]' - const nextQuestionButton = '[data-cy=next-question-button]' - const questionsButtonsContainer = '[data-cy=buttons-container]' - const loadingSpinner = '[data-cy=loading-spinner]' - const generateButton = '[data-cy=generate-button]' - const mockProps = { - sender: { name: 'user', id: '1234' }, - messageId: '123', - supportedElements: { question: Question }, - } - beforeEach(() => { - const stubbedFunctions = { - onCancel: cy.stub().as('onCancel'), - onSubmit: cy.stub().as('onSubmit'), - ws: getMockWebSocketClient(webSocketUrl), - } - cy.mount() - }) - it('renders the component', () => { - cy.get(promptBuilder).should('be.visible') - cy.get(componentTitle).should('be.visible') - cy.get(quitButton).should('be.visible') - cy.get(nextQuestionButton).should('be.visible') - }) - it('shows quit dialog when attempting to quit', () => { - cy.get(quitButton).click() - cy.get(quitDialogTitle).should('be.visible') - }) - it('executes onClose when the user quits', () => { - cy.get(quitButton).click() - cy.get(confirmQuitButton).click() - cy.get('@onCancel').should('be.called') - }) - it('does not quit when the user clicks "Continue build"', () => { - cy.get(quitButton).click() - cy.get(continueBuildButton).click() - cy.get(quitDialogTitle).should('not.be.visible') - cy.get(promptBuilder).should('be.visible') - }) - it('should disable the "Next question" button when the user has not selected an option', () => { - cy.get(questionsButtonsContainer) - .children() - .each((button) => { - cy.wrap(button).should('have.attr', 'aria-disabled', 'false') - }) - cy.get(nextQuestionButton).should('be.disabled') - }) - it('should enable the "Next question" button when the user has selected an option', () => { - cy.get(questionsButtonsContainer).children().first().click() - cy.get(nextQuestionButton).should('not.be.disabled') - }) - it('should not show the generate button when no message has indicated that it is ready', () => { - cy.get(generateButton).should('not.exist') - }) - it('shows a loading indicator when waiting for the next question', () => { - cy.get(questionsButtonsContainer).children().first().click() - cy.get(nextQuestionButton).click() - cy.get(loadingSpinner).should('be.visible') - }) - it('handles Generate button click', () => { - cy.get(questionsButtonsContainer).children().first().click() - cy.get(nextQuestionButton).click() - cy.get(generateButton).should('be.visible') - cy.get(generateButton).click() - cy.get(loadingSpinner).should('be.visible') - cy.get('@onSubmit').should('be.called') - }) -}) +// describe('PromptBuilder Component', () => { +// const promptBuilder = '[data-cy=prompt-builder]' +// const componentTitle = '[data-cy=component-title]' +// const quitButton = '[data-cy=quit-button]' +// const quitDialogTitle = '[data-cy=quit-dialog-title]' +// const confirmQuitButton = '[data-cy=confirm-quit-button]' +// const continueBuildButton = '[data-cy=continue-build-button]' +// const nextQuestionButton = '[data-cy=next-question-button]' +// const questionsButtonsContainer = '[data-cy=buttons-container]' +// const loadingSpinner = '[data-cy=loading-spinner]' +// const generateButton = '[data-cy=generate-button]' +// const mockProps = { +// sender: { name: 'user', id: '1234' }, +// messageId: '123', +// supportedElements: { question: Question }, +// } +// beforeEach(() => { +// const stubbedFunctions = { +// onCancel: cy.stub().as('onCancel'), +// onSubmit: cy.stub().as('onSubmit'), +// ws: getMockWebSocketClient(webSocketUrl), +// } +// cy.mount() +// }) +// it('renders the component', () => { +// cy.get(promptBuilder).should('be.visible') +// cy.get(componentTitle).should('be.visible') +// cy.get(quitButton).should('be.visible') +// cy.get(nextQuestionButton).should('be.visible') +// }) +// it('shows quit dialog when attempting to quit', () => { +// cy.get(quitButton).click() +// cy.get(quitDialogTitle).should('be.visible') +// }) +// it('executes onClose when the user quits', () => { +// cy.get(quitButton).click() +// cy.get(confirmQuitButton).click() +// cy.get('@onCancel').should('be.called') +// }) +// it('does not quit when the user clicks "Continue build"', () => { +// cy.get(quitButton).click() +// cy.get(continueBuildButton).click() +// cy.get(quitDialogTitle).should('not.be.visible') +// cy.get(promptBuilder).should('be.visible') +// }) +// it('should disable the "Next question" button when the user has not selected an option', () => { +// cy.get(questionsButtonsContainer) +// .children() +// .each((button) => { +// cy.wrap(button).should('have.attr', 'aria-disabled', 'false') +// }) +// cy.get(nextQuestionButton).should('be.disabled') +// }) +// it('should enable the "Next question" button when the user has selected an option', () => { +// cy.get(questionsButtonsContainer).children().first().click() +// cy.get(nextQuestionButton).should('not.be.disabled') +// }) +// it('should not show the generate button when no message has indicated that it is ready', () => { +// cy.get(generateButton).should('not.exist') +// }) +// it('shows a loading indicator when waiting for the next question', () => { +// cy.get(questionsButtonsContainer).children().first().click() +// cy.get(nextQuestionButton).click() +// cy.get(loadingSpinner).should('be.visible') +// }) +// it('handles Generate button click', () => { +// cy.get(questionsButtonsContainer).children().first().click() +// cy.get(nextQuestionButton).click() +// cy.get(generateButton).should('be.visible') +// cy.get(generateButton).click() +// cy.get(loadingSpinner).should('be.visible') +// cy.get('@onSubmit').should('be.called') +// }) +// }) diff --git a/src/components/promptBuilder/promptBuilder.tsx b/src/components/promptBuilder/promptBuilder.tsx index 9963bfff..42b1b0b3 100644 --- a/src/components/promptBuilder/promptBuilder.tsx +++ b/src/components/promptBuilder/promptBuilder.tsx @@ -55,9 +55,8 @@ export default function PromptBuilder(props: PromptBuilderProps) { }, } - function handleIncomingMessage(event: MessageEvent) { - const receivedMessage = JSON.parse(event.data) - setMessages((prev) => [...prev, receivedMessage]) + function handleIncomingMessage(message: Message) { + setMessages((prev) => [...prev, message]) } useEffect(() => { @@ -121,7 +120,7 @@ export default function PromptBuilder(props: PromptBuilderProps) { key={message.id} ws={inputCapturer} sender={props.sender} - message={message} + messages={message} supportedElements={props.supportedElements} /> ) diff --git a/src/components/types.ts b/src/components/types.ts index 7fbda4f8..c4b0f9f9 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -39,7 +39,7 @@ export interface WebSocketClient { send: (message: Message) => void close: () => void reconnect: () => void - onReceive?: (handler: (event: MessageEvent) => void) => void + onReceive?: (handler: (message: Message) => void) => void } export enum ParticipantRole { From ee213d84bd575559732a5c84bf58401d4b497f68 Mon Sep 17 00:00:00 2001 From: Eileen Li Date: Tue, 30 Jul 2024 16:04:39 -0700 Subject: [PATCH 2/7] fix: fix promptBuilder --- .../promptBuilder/promptBuilder.cy.tsx | 190 +++++++++--------- .../promptBuilder/promptBuilder.tsx | 2 +- 2 files changed, 96 insertions(+), 96 deletions(-) diff --git a/src/components/promptBuilder/promptBuilder.cy.tsx b/src/components/promptBuilder/promptBuilder.cy.tsx index 722691f6..ca178507 100644 --- a/src/components/promptBuilder/promptBuilder.cy.tsx +++ b/src/components/promptBuilder/promptBuilder.cy.tsx @@ -1,100 +1,100 @@ -// import { Server } from 'mock-socket' +import { Server } from 'mock-socket' -// import Question from '../question/question' -// import { getMockWebSocketClient, sendMessageToClient } from './mockWebSocket' -// import PromptBuilder from './promptBuilder' +import Question from '../question/question' +import { getMockWebSocketClient, sendMessageToClient } from './mockWebSocket' +import PromptBuilder from './promptBuilder' -// const webSocketUrl = 'ws://localhost:8081' -// const server = new Server(webSocketUrl) +const webSocketUrl = 'ws://localhost:8081' +const server = new Server(webSocketUrl) -// server.on('connection', (socket) => { -// sendMessageToClient(socket, 'question', { -// title: 'What is your main goal?', -// options: [ -// 'grow my business', -// 'sell internationally', -// 'hire or train employees', -// ], -// }) +server.on('connection', (socket) => { + sendMessageToClient(socket, 'question', { + title: 'What is your main goal?', + options: [ + 'grow my business', + 'sell internationally', + 'hire or train employees', + ], + }) -// socket.on('message', () => { -// sendMessageToClient(socket, 'promptBuilder', { -// isLastQuestion: true, -// }) -// }) -// }) + socket.on('message', () => { + sendMessageToClient(socket, 'promptBuilder', { + isLastQuestion: true, + }) + }) +}) -// describe('PromptBuilder Component', () => { -// const promptBuilder = '[data-cy=prompt-builder]' -// const componentTitle = '[data-cy=component-title]' -// const quitButton = '[data-cy=quit-button]' -// const quitDialogTitle = '[data-cy=quit-dialog-title]' -// const confirmQuitButton = '[data-cy=confirm-quit-button]' -// const continueBuildButton = '[data-cy=continue-build-button]' -// const nextQuestionButton = '[data-cy=next-question-button]' -// const questionsButtonsContainer = '[data-cy=buttons-container]' -// const loadingSpinner = '[data-cy=loading-spinner]' -// const generateButton = '[data-cy=generate-button]' -// const mockProps = { -// sender: { name: 'user', id: '1234' }, -// messageId: '123', -// supportedElements: { question: Question }, -// } -// beforeEach(() => { -// const stubbedFunctions = { -// onCancel: cy.stub().as('onCancel'), -// onSubmit: cy.stub().as('onSubmit'), -// ws: getMockWebSocketClient(webSocketUrl), -// } -// cy.mount() -// }) -// it('renders the component', () => { -// cy.get(promptBuilder).should('be.visible') -// cy.get(componentTitle).should('be.visible') -// cy.get(quitButton).should('be.visible') -// cy.get(nextQuestionButton).should('be.visible') -// }) -// it('shows quit dialog when attempting to quit', () => { -// cy.get(quitButton).click() -// cy.get(quitDialogTitle).should('be.visible') -// }) -// it('executes onClose when the user quits', () => { -// cy.get(quitButton).click() -// cy.get(confirmQuitButton).click() -// cy.get('@onCancel').should('be.called') -// }) -// it('does not quit when the user clicks "Continue build"', () => { -// cy.get(quitButton).click() -// cy.get(continueBuildButton).click() -// cy.get(quitDialogTitle).should('not.be.visible') -// cy.get(promptBuilder).should('be.visible') -// }) -// it('should disable the "Next question" button when the user has not selected an option', () => { -// cy.get(questionsButtonsContainer) -// .children() -// .each((button) => { -// cy.wrap(button).should('have.attr', 'aria-disabled', 'false') -// }) -// cy.get(nextQuestionButton).should('be.disabled') -// }) -// it('should enable the "Next question" button when the user has selected an option', () => { -// cy.get(questionsButtonsContainer).children().first().click() -// cy.get(nextQuestionButton).should('not.be.disabled') -// }) -// it('should not show the generate button when no message has indicated that it is ready', () => { -// cy.get(generateButton).should('not.exist') -// }) -// it('shows a loading indicator when waiting for the next question', () => { -// cy.get(questionsButtonsContainer).children().first().click() -// cy.get(nextQuestionButton).click() -// cy.get(loadingSpinner).should('be.visible') -// }) -// it('handles Generate button click', () => { -// cy.get(questionsButtonsContainer).children().first().click() -// cy.get(nextQuestionButton).click() -// cy.get(generateButton).should('be.visible') -// cy.get(generateButton).click() -// cy.get(loadingSpinner).should('be.visible') -// cy.get('@onSubmit').should('be.called') -// }) -// }) +describe('PromptBuilder Component', () => { + const promptBuilder = '[data-cy=prompt-builder]' + const componentTitle = '[data-cy=component-title]' + const quitButton = '[data-cy=quit-button]' + const quitDialogTitle = '[data-cy=quit-dialog-title]' + const confirmQuitButton = '[data-cy=confirm-quit-button]' + const continueBuildButton = '[data-cy=continue-build-button]' + const nextQuestionButton = '[data-cy=next-question-button]' + const questionsButtonsContainer = '[data-cy=buttons-container]' + const loadingSpinner = '[data-cy=loading-spinner]' + const generateButton = '[data-cy=generate-button]' + const mockProps = { + sender: { name: 'user', id: '1234' }, + messageId: '123', + supportedElements: { question: Question }, + } + beforeEach(() => { + const stubbedFunctions = { + onCancel: cy.stub().as('onCancel'), + onSubmit: cy.stub().as('onSubmit'), + ws: getMockWebSocketClient(webSocketUrl), + } + cy.mount() + }) + it('renders the component', () => { + cy.get(promptBuilder).should('be.visible') + cy.get(componentTitle).should('be.visible') + cy.get(quitButton).should('be.visible') + cy.get(nextQuestionButton).should('be.visible') + }) + it('shows quit dialog when attempting to quit', () => { + cy.get(quitButton).click() + cy.get(quitDialogTitle).should('be.visible') + }) + it('executes onClose when the user quits', () => { + cy.get(quitButton).click() + cy.get(confirmQuitButton).click() + cy.get('@onCancel').should('be.called') + }) + it('does not quit when the user clicks "Continue build"', () => { + cy.get(quitButton).click() + cy.get(continueBuildButton).click() + cy.get(quitDialogTitle).should('not.be.visible') + cy.get(promptBuilder).should('be.visible') + }) + it('should disable the "Next question" button when the user has not selected an option', () => { + cy.get(questionsButtonsContainer) + .children() + .each((button) => { + cy.wrap(button).should('have.attr', 'aria-disabled', 'false') + }) + cy.get(nextQuestionButton).should('be.disabled') + }) + it('should enable the "Next question" button when the user has selected an option', () => { + cy.get(questionsButtonsContainer).children().first().click() + cy.get(nextQuestionButton).should('not.be.disabled') + }) + it('should not show the generate button when no message has indicated that it is ready', () => { + cy.get(generateButton).should('not.exist') + }) + it('shows a loading indicator when waiting for the next question', () => { + cy.get(questionsButtonsContainer).children().first().click() + cy.get(nextQuestionButton).click() + cy.get(loadingSpinner).should('be.visible') + }) + it('handles Generate button click', () => { + cy.get(questionsButtonsContainer).children().first().click() + cy.get(nextQuestionButton).click() + cy.get(generateButton).should('be.visible') + cy.get(generateButton).click() + cy.get(loadingSpinner).should('be.visible') + cy.get('@onSubmit').should('be.called') + }) +}) diff --git a/src/components/promptBuilder/promptBuilder.tsx b/src/components/promptBuilder/promptBuilder.tsx index 42b1b0b3..5e3826ad 100644 --- a/src/components/promptBuilder/promptBuilder.tsx +++ b/src/components/promptBuilder/promptBuilder.tsx @@ -120,7 +120,7 @@ export default function PromptBuilder(props: PromptBuilderProps) { key={message.id} ws={inputCapturer} sender={props.sender} - messages={message} + messages={[message]} supportedElements={props.supportedElements} /> ) From 6cfd5bdbdc2490d47e065483f283717f910e68dc Mon Sep 17 00:00:00 2001 From: Eileen Li Date: Tue, 30 Jul 2024 16:22:07 -0700 Subject: [PATCH 3/7] fix: remove the usage of threadableMessage --- src/components/messageCanvas/actions/action.tsx | 6 +++--- .../messageCanvas/actions/copy/copyText.tsx | 6 +++--- .../actions/textToSpeech/textToSpeech.cy.tsx | 4 ++-- .../actions/textToSpeech/textToSpeech.tsx | 4 ++-- src/components/messageCanvas/messageCanvas.tsx | 10 +++++----- .../messageSpace/messageSpace.stories.tsx | 17 +++++++---------- .../promptBuilder/promptBuilder.stories.tsx | 2 +- src/components/types.ts | 5 ----- 8 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/components/messageCanvas/actions/action.tsx b/src/components/messageCanvas/actions/action.tsx index 67998b0e..4971bdbf 100644 --- a/src/components/messageCanvas/actions/action.tsx +++ b/src/components/messageCanvas/actions/action.tsx @@ -5,13 +5,13 @@ import Tooltip from '@mui/material/Tooltip' import type { ReactNode } from 'react' import React from 'react' -import type { ThreadableMessage } from '../../types' +import type { Message } from '../../types' interface ActionProps { label: string - message: ThreadableMessage + message: Message icon: ReactNode - onClick: (message: ThreadableMessage) => void + onClick: (message: Message) => void } export default function Action(props: ActionProps) { diff --git a/src/components/messageCanvas/actions/copy/copyText.tsx b/src/components/messageCanvas/actions/copy/copyText.tsx index 506020b5..1c433d56 100644 --- a/src/components/messageCanvas/actions/copy/copyText.tsx +++ b/src/components/messageCanvas/actions/copy/copyText.tsx @@ -1,18 +1,18 @@ import React, { useState } from 'react' import Icon from '../../../icon/icon' -import type { ThreadableMessage } from '../../../types' +import type { Message } from '../../../types' import Action from '../index' export interface CopyTextProps { - message: ThreadableMessage + message: Message } export default function CopyText(props: CopyTextProps) { const [tooltipContent, setTooltipContent] = useState('Copy text') const twoSeconds = 2000 - function handleOnClick(message: ThreadableMessage) { + function handleOnClick(message: Message) { if (message.data.text) { navigator.clipboard .writeText(message.data.text) diff --git a/src/components/messageCanvas/actions/textToSpeech/textToSpeech.cy.tsx b/src/components/messageCanvas/actions/textToSpeech/textToSpeech.cy.tsx index 2958726c..59a8afbd 100644 --- a/src/components/messageCanvas/actions/textToSpeech/textToSpeech.cy.tsx +++ b/src/components/messageCanvas/actions/textToSpeech/textToSpeech.cy.tsx @@ -1,8 +1,8 @@ -import type { ThreadableMessage } from '../../../types' // Adjust if needed +import type { Message } from '../../../types' // Adjust if needed import TextToSpeech from './textToSpeech' describe('TextToSpeech Component', () => { - const mockMessage: ThreadableMessage = { + const mockMessage: Message = { id: '1', timestamp: '2020-01-02T00:00:00.000Z', conversationId: 'lkd9vc', diff --git a/src/components/messageCanvas/actions/textToSpeech/textToSpeech.tsx b/src/components/messageCanvas/actions/textToSpeech/textToSpeech.tsx index 1e106f2c..fd0f4cfe 100644 --- a/src/components/messageCanvas/actions/textToSpeech/textToSpeech.tsx +++ b/src/components/messageCanvas/actions/textToSpeech/textToSpeech.tsx @@ -1,11 +1,11 @@ import React, { useState } from 'react' import Icon from '../../../icon/icon' -import type { ThreadableMessage } from '../../../types' +import type { Message } from '../../../types' import Action from '../index' export interface TextToSpeechProps { - message: ThreadableMessage + message: Message } export default function TextToSpeech(props: TextToSpeechProps) { diff --git a/src/components/messageCanvas/messageCanvas.tsx b/src/components/messageCanvas/messageCanvas.tsx index 1eff8b88..220aa066 100644 --- a/src/components/messageCanvas/messageCanvas.tsx +++ b/src/components/messageCanvas/messageCanvas.tsx @@ -6,23 +6,23 @@ import Stack from '@mui/material/Stack' import React, { forwardRef, type ReactNode } from 'react' import Timestamp from '../timestamp/timestamp' -import type { ThreadableMessage } from '../types' +import type { Message } from '../types' export interface MessageContainerProps { /** A function that returns a React element to display sender details, like names and/or avatars. */ - getProfileComponent?: (message: ThreadableMessage) => ReactNode + getProfileComponent?: (message: Message) => ReactNode /** A function that returns a single React element which may be composed of several actions supported for the message, such as editing, copying, and deleting, etc. * In case no actions are applicable or available for a particular message, the function may return `undefined`. * This approach offers flexibility in tailoring message interactions to specific application requirements. * To define individual message actions, developers can extend the `Action` component's functionality. * One such example is the `CopyText` component. */ - getActionsComponent?: (message: ThreadableMessage) => ReactNode | undefined + getActionsComponent?: (message: Message) => ReactNode | undefined } export interface MessageCanvasProps extends MessageContainerProps { - /** Message information to be displayed. Please see the `MessageSpace` docs for more information about the `ThreadableMessage` interface. */ - message: ThreadableMessage + /** Message information to be displayed. Please see the `MessageSpace` docs for more information about the `Message` interface. */ + message: Message /** React component to be displayed in the message canvas. */ children: ReactNode } diff --git a/src/components/messageSpace/messageSpace.stories.tsx b/src/components/messageSpace/messageSpace.stories.tsx index 09eeedb9..c895d3f9 100644 --- a/src/components/messageSpace/messageSpace.stories.tsx +++ b/src/components/messageSpace/messageSpace.stories.tsx @@ -9,6 +9,7 @@ import { Image, MarkedMarkdown, MarkedStreamingMarkdown, + type Message, Multipart, OpenLayersMap, RechartsTimeSeries, @@ -16,7 +17,6 @@ import { StreamingText, Table, Text, - type ThreadableMessage, Video, YoutubeVideo, } from '..' @@ -45,7 +45,7 @@ const meta: Meta> = { export default meta -function getProfileIcon(message: ThreadableMessage) { +function getProfileIcon(message: Message) { if (message.sender.name?.toLowerCase().includes('agent')) { return } else { @@ -53,7 +53,7 @@ function getProfileIcon(message: ThreadableMessage) { } } -function getProfileIconAndName(message: ThreadableMessage) { +function getProfileIconAndName(message: Message) { return ( <> {getProfileIcon(message)} @@ -68,9 +68,9 @@ meta.argTypes = { messages: { table: { type: { - summary: 'Array of ThreadableMessage.\n', + summary: 'Array of Message.\n', detail: - 'ThreadableMessage extends the Message interface which has the following fields:\n' + + 'Message interface has the following fields:\n' + ' id: A string representing the unique identifier of the message.\n' + ' timestamp: A string representing the timestamp of the message.\n' + ' sender: An object representing the sender of the message. Refer to the `sender` prop.\n' + @@ -81,10 +81,7 @@ meta.argTypes = { ' threadId: An optional string representing the identifier of the thread to which this message belongs.\n' + ' priority: An optional string representing the priority of the message.\n' + ' taggedParticipants: An optional array of strings representing the participants tagged in the message.\n' + - ' topic: An optional string representing the identifier of the topic associated with the message.\n' + - 'Other than the fields described above, ThreadableMessage also has the following fields:\n' + - ' lastThreadMessage: An optional object of Message interface representing the last message in the thread.\n' + - ' threadMessagesData: An optional array of objects of type MessageData, which can contain any key-value pairs.', + ' topic: An optional string representing the identifier of the topic associated with the message.\n', }, }, }, @@ -477,7 +474,7 @@ export const Default = { multipart: Multipart, }, getProfileComponent: getProfileIconAndName, - getActionsComponent: (message: ThreadableMessage) => { + getActionsComponent: (message: Message) => { const copyButton = message.format === 'text' && ( ) diff --git a/src/components/promptBuilder/promptBuilder.stories.tsx b/src/components/promptBuilder/promptBuilder.stories.tsx index 9491557d..5037af72 100644 --- a/src/components/promptBuilder/promptBuilder.stories.tsx +++ b/src/components/promptBuilder/promptBuilder.stories.tsx @@ -85,7 +85,7 @@ meta.argTypes = { type: 'function', table: { type: { - summary: '(message: ThreadableMessage) => ReactNode', + summary: '(message: Message) => ReactNode', }, }, }, diff --git a/src/components/types.ts b/src/components/types.ts index c4b0f9f9..b0c60c0b 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -25,11 +25,6 @@ export interface Message { topic?: string } -export interface ThreadableMessage extends Message { - lastThreadMessage?: Message - threadMessagesData?: MessageData[] -} - export interface ComponentMap { // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: React.ComponentType From 9142f4cc8af617383b4a6f403a94705cd371fc68 Mon Sep 17 00:00:00 2001 From: Eileen Li Date: Wed, 31 Jul 2024 13:15:28 -0700 Subject: [PATCH 4/7] fix: update test --- .../messageSpace/messageSpace.cy.tsx | 120 ++++++++++++++++++ src/components/messageSpace/messageSpace.tsx | 19 +-- .../{promptBuilder => }/mockWebSocket.ts | 30 +++-- .../promptBuilder/promptBuilder.cy.tsx | 2 +- .../promptBuilder/promptBuilder.stories.tsx | 2 +- 5 files changed, 145 insertions(+), 28 deletions(-) rename src/components/{promptBuilder => }/mockWebSocket.ts (60%) diff --git a/src/components/messageSpace/messageSpace.cy.tsx b/src/components/messageSpace/messageSpace.cy.tsx index 8c875bf0..6575d927 100644 --- a/src/components/messageSpace/messageSpace.cy.tsx +++ b/src/components/messageSpace/messageSpace.cy.tsx @@ -1,5 +1,6 @@ import 'cypress-real-events' +import { Server } from 'mock-socket' import { v4 as getUUID } from 'uuid' import { @@ -21,6 +22,7 @@ import { YoutubeVideo, } from '..' import Icon from '../icon/icon' +import { getMockWebSocketClient } from '../mockWebSocket' import MessageSpace from './messageSpace' describe('MessageSpace Component', () => { @@ -80,7 +82,80 @@ describe('MessageSpace Component', () => { ] const messageSpace = '[data-cy=message-space]' + const webSocketUrl = 'ws://localhost:8082' + const streamingTextRootMessageId = getUUID() + const messagesToBeSent = [ + { + ...humanMessageData, + id: getUUID(), + timestamp: new Date().toISOString(), + format: 'text', + data: { + text: 'Could you show me an example of the streaming text component?', + }, + }, + { + ...agentMessageData, + id: streamingTextRootMessageId, + timestamp: new Date().toISOString(), + format: 'streamingText', + data: { + text: 'Sure!', + }, + }, + { + ...agentMessageData, + id: getUUID(), + threadId: streamingTextRootMessageId, + timestamp: new Date().toISOString(), + format: 'updateStreamingText', + data: { + text: ' The text', + }, + }, + { + ...agentMessageData, + id: streamingTextRootMessageId, + threadId: streamingTextRootMessageId, + timestamp: new Date().toISOString(), + format: 'updateStreamingText', + data: { + text: ' is displayed', + }, + }, + { + ...agentMessageData, + id: streamingTextRootMessageId, + threadId: streamingTextRootMessageId, + timestamp: new Date().toISOString(), + format: 'updateStreamingText', + data: { + text: ' progressively.', + }, + }, + ] + let server: Server | null + + const setupWebSocketServer = () => { + server = new Server(webSocketUrl) + const serverDelay = 50 + server.on('connection', (socket) => { + messagesToBeSent.forEach((message, index) => { + setTimeout( + () => socket.send(JSON.stringify(message)), + serverDelay + index * serverDelay + ) + }) + }) + } + + const teardownWebSocketServer = () => { + if (server) { + server.stop() + server = null + } + } supportedViewports.forEach((viewport) => { it(`renders correctly with provided messages on ${viewport} screen`, () => { const mockWsClient = { @@ -127,6 +202,51 @@ describe('MessageSpace Component', () => { }) }) + it(`can receive and render messages from websocket on ${viewport} screen`, () => { + setupWebSocketServer() + cy.viewport(viewport) + cy.mount( + { + if (message.sender.name?.includes('Agent')) { + return + } else { + return + } + }} + /> + ) + const messageSpace = '[data-cy=message-space]' + cy.get(messageSpace).should('exist') + cy.get(messageSpace).should('contain', 'Existing message') + cy.get(messageSpace).should( + 'not.contain', + 'Sure! The text is displayed progressively.' + ) + messagesToBeSent.forEach((message) => { + cy.get(messageSpace).should('contain', message.data.text) + }) + cy.get(messageSpace).should( + 'contain', + 'Sure! The text is displayed progressively.' + ) + teardownWebSocketServer() + }) + it(`scrolls to bottom when "Go to bottom" button is clicked on ${viewport} screen`, () => { const waitTime = 500 diff --git a/src/components/messageSpace/messageSpace.tsx b/src/components/messageSpace/messageSpace.tsx index 71dc7117..e2498bbc 100644 --- a/src/components/messageSpace/messageSpace.tsx +++ b/src/components/messageSpace/messageSpace.tsx @@ -146,21 +146,6 @@ export default function MessageSpace(props: MessageSpaceProps) { scrollDownIfNeeded() }, [areVideosLoaded]) - function scrollToLastMessage() { - if (getVideoStatus()) { - const lastMessage = scrollEndRef.current - - if (lastMessage) { - // Use setTimeout to delay smooth scrolling - setTimeout(() => { - lastMessage.scrollIntoView({ block: 'start', inline: 'nearest' }) - }, 0) - } - } else { - setTimeout(scrollToLastMessage, 1) - } - } - useEffect(() => { const hasNewMessage = previousMessagesLength !== 0 && @@ -168,7 +153,7 @@ export default function MessageSpace(props: MessageSpaceProps) { if (isScrolledToBottom && hasNewMessage) { hideScrollButton() - scrollToLastMessage() + scrollDownIfNeeded() } }, [isScrolledToBottom, Object.keys(chatMessages).length]) @@ -181,7 +166,7 @@ export default function MessageSpace(props: MessageSpaceProps) { }) setChatMessages(messageDict) - }, [props.messages]) + }, [props.messages?.length]) function handleIncomingMessage(message: Message) { setChatMessages((prevMessages) => diff --git a/src/components/promptBuilder/mockWebSocket.ts b/src/components/mockWebSocket.ts similarity index 60% rename from src/components/promptBuilder/mockWebSocket.ts rename to src/components/mockWebSocket.ts index a507c501..7f7f7fb3 100644 --- a/src/components/promptBuilder/mockWebSocket.ts +++ b/src/components/mockWebSocket.ts @@ -1,7 +1,7 @@ import { type Client, WebSocket } from 'mock-socket' import { v4 as getUUID } from 'uuid' -import type { Message, MessageData, WebSocketClient } from '../types' +import type { Message, MessageData, WebSocketClient } from './types' const serverDelay = 500 @@ -25,24 +25,36 @@ export function sendMessageToClient( } export function getMockWebSocketClient(webSocketUrl: string): WebSocketClient { - let ws = new WebSocket(webSocketUrl) + let ws: WebSocket | null = null + const connect = (): WebSocket => { + const socket = new WebSocket(webSocketUrl) + socket.onopen = () => {} + return socket + } + ws = connect() return { send: (message: Message) => { - ws.send(JSON.stringify(message)) + if (ws) { + ws.send(JSON.stringify(message)) + } }, close: () => { - ws.close() + if (ws) { + ws.close() + } }, reconnect: () => { - if (ws.readyState === WebSocket.CLOSED) { - ws = new WebSocket(webSocketUrl) + if (ws && ws.readyState === WebSocket.CLOSED) { + ws = connect() } }, onReceive: (handler: (message: Message) => void) => { - ws.onmessage = (event) => { - const receivedMessage = JSON.parse(event.data) - handler(receivedMessage) + if (ws) { + ws.onmessage = (event) => { + const receivedMessage = JSON.parse(event.data) + handler(receivedMessage) + } } }, } diff --git a/src/components/promptBuilder/promptBuilder.cy.tsx b/src/components/promptBuilder/promptBuilder.cy.tsx index ca178507..d1a1b54b 100644 --- a/src/components/promptBuilder/promptBuilder.cy.tsx +++ b/src/components/promptBuilder/promptBuilder.cy.tsx @@ -1,7 +1,7 @@ import { Server } from 'mock-socket' +import { getMockWebSocketClient, sendMessageToClient } from '../mockWebSocket' import Question from '../question/question' -import { getMockWebSocketClient, sendMessageToClient } from './mockWebSocket' import PromptBuilder from './promptBuilder' const webSocketUrl = 'ws://localhost:8081' diff --git a/src/components/promptBuilder/promptBuilder.stories.tsx b/src/components/promptBuilder/promptBuilder.stories.tsx index 5037af72..041579c3 100644 --- a/src/components/promptBuilder/promptBuilder.stories.tsx +++ b/src/components/promptBuilder/promptBuilder.stories.tsx @@ -14,10 +14,10 @@ import { v4 as getUUID } from 'uuid' import TextInput from '../input/textInput/textInput' import MessageSpace from '../messageSpace/messageSpace' +import { getMockWebSocketClient, sendMessageToClient } from '../mockWebSocket' import Question from '../question/question' import Text from '../text/text' import type { Message, QuestionProps } from '../types' -import { getMockWebSocketClient, sendMessageToClient } from './mockWebSocket' import PromptBuilder from './promptBuilder' const meta: Meta> = { From bd11a0c0b65472c0c8c10c3014a8dd4e87e17af7 Mon Sep 17 00:00:00 2001 From: Eileen Li Date: Wed, 31 Jul 2024 14:19:30 -0700 Subject: [PATCH 5/7] docs: update storybook --- src/components/input/textInput/textInput.stories.tsx | 3 ++- src/components/messageSpace/messageSpace.stories.tsx | 6 +++++- src/components/messageSpace/messageSpace.tsx | 4 +++- src/components/promptBuilder/promptBuilder.stories.tsx | 2 +- src/components/question/question.stories.tsx | 6 +++++- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/components/input/textInput/textInput.stories.tsx b/src/components/input/textInput/textInput.stories.tsx index a83d9d57..96e64af7 100644 --- a/src/components/input/textInput/textInput.stories.tsx +++ b/src/components/input/textInput/textInput.stories.tsx @@ -58,7 +58,8 @@ meta.argTypes = { 'A websocket client with supports the following methods:\n' + 'send: (msg: Message) => void\n' + 'close: () => void\n' + - 'reconnect: () => void', + 'reconnect: () => void\n' + + 'onReceive?: (handler: (message: Message) => void) => void', }, }, }, diff --git a/src/components/messageSpace/messageSpace.stories.tsx b/src/components/messageSpace/messageSpace.stories.tsx index c895d3f9..798ae630 100644 --- a/src/components/messageSpace/messageSpace.stories.tsx +++ b/src/components/messageSpace/messageSpace.stories.tsx @@ -90,7 +90,11 @@ meta.argTypes = { type: { summary: 'WebSocketClient', detail: - 'send: (message: Message) => void\nclose: () => void\nreconnect: () => void\n', + 'A websocket client with supports the following methods:\n' + + 'send: (msg: Message) => void\n' + + 'close: () => void\n' + + 'reconnect: () => void\n' + + 'onReceive?: (handler: (message: Message) => void) => void', }, }, }, diff --git a/src/components/messageSpace/messageSpace.tsx b/src/components/messageSpace/messageSpace.tsx index e2498bbc..515b07f8 100644 --- a/src/components/messageSpace/messageSpace.tsx +++ b/src/components/messageSpace/messageSpace.tsx @@ -49,7 +49,9 @@ function getCombinedMessages( } /** - The `MessageSpace` component uses `MessageCanvas` and `ElementRenderer` to render a list of messages. It serves as a container for individual message items, each encapsulated within a `MessageCanvas` for consistent styling and layout. + The `MessageSpace` component uses `MessageCanvas` and `ElementRenderer` to render a list of messages. It serves as a container for individual message items, each encapsulated within a `MessageCanvas` for consistent styling and layout. It can receive and process messages to dynamically update the displayed content. + + The `MessageSpace` component can combine update messages with the original message and render them as a single message. For this to work, the `threadId` of the update message must match the `id` of the original message, and the format of the update message must include 'update'. Note: For more information about the `getActionsComponent` and `getProfileComponent` fields, refer to the [MessageCanvas' docs](http://localhost:6006/?path=/docs/rustic-ui-message-canvas-message-canvas--docs). */ diff --git a/src/components/promptBuilder/promptBuilder.stories.tsx b/src/components/promptBuilder/promptBuilder.stories.tsx index 041579c3..55f2d9e0 100644 --- a/src/components/promptBuilder/promptBuilder.stories.tsx +++ b/src/components/promptBuilder/promptBuilder.stories.tsx @@ -53,7 +53,7 @@ meta.argTypes = { 'send: (msg: Message) => void\n' + 'close: () => void\n' + 'reconnect: () => void\n' + - 'onReceive?: (handler: (event: MessageEvent) => void) => void\n', + 'onReceive?: (handler: (message: Message) => void) => void', }, }, }, diff --git a/src/components/question/question.stories.tsx b/src/components/question/question.stories.tsx index 4ecc1b30..808920ba 100644 --- a/src/components/question/question.stories.tsx +++ b/src/components/question/question.stories.tsx @@ -32,7 +32,11 @@ meta.argTypes = { type: { summary: 'WebSocketClient', detail: - 'send: (message: Message) => void\nclose: () => void\nreconnect: () => void\n', + 'A websocket client with supports the following methods:\n' + + 'send: (msg: Message) => void\n' + + 'close: () => void\n' + + 'reconnect: () => void\n' + + 'onReceive?: (handler: (message: Message) => void) => void', }, }, }, From 6fd5b5efa4f8a7ee106f45ec54af9b0cabf0df2e Mon Sep 17 00:00:00 2001 From: Eileen Li Date: Wed, 31 Jul 2024 16:59:41 -0700 Subject: [PATCH 6/7] fix: minor updates to elementRenderer --- .../elementRenderer/elementRenderer.cy.tsx | 29 ++++++++++++++++--- .../elementRenderer/elementRenderer.tsx | 4 ++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/components/elementRenderer/elementRenderer.cy.tsx b/src/components/elementRenderer/elementRenderer.cy.tsx index 81d90b9e..c45905d3 100644 --- a/src/components/elementRenderer/elementRenderer.cy.tsx +++ b/src/components/elementRenderer/elementRenderer.cy.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { v4 as getUUID } from 'uuid' import { supportedViewports, @@ -89,17 +90,37 @@ describe('ElementRenderer', () => { messages={[ { ...sampleMessage, - data: { text: 'Test' }, + data: { text: 'This' }, format: 'streamingText', }, { - id: '2', + id: getUUID(), timestamp: '2020-01-02T00:00:00.000Z', sender: testUser, conversationId: 'lkd9vc', topic: 'default', threadId: '1', - data: { text: ' Text' }, + data: { text: ' is' }, + format: 'streamingText', + }, + { + id: getUUID(), + timestamp: '2020-01-02T00:00:00.000Z', + sender: testUser, + conversationId: 'lkd9vc', + topic: 'default', + threadId: '1', + data: { text: ' streaming' }, + format: 'streamingText', + }, + { + id: getUUID(), + timestamp: '2020-01-02T00:00:00.000Z', + sender: testUser, + conversationId: 'lkd9vc', + topic: 'default', + threadId: '1', + data: { text: ' text.' }, format: 'streamingText', }, ]} @@ -107,7 +128,7 @@ describe('ElementRenderer', () => { ws={mockWsClient} /> ) - cy.get('p').should('contain.text', 'Test Text') + cy.get('p').should('contain.text', 'This is streaming text.') }) it(`renders a message for an unsupported format on ${viewport} screen`, () => { diff --git a/src/components/elementRenderer/elementRenderer.tsx b/src/components/elementRenderer/elementRenderer.tsx index a6dc42f9..9c987b63 100644 --- a/src/components/elementRenderer/elementRenderer.tsx +++ b/src/components/elementRenderer/elementRenderer.tsx @@ -24,7 +24,9 @@ const ElementRenderer = (props: ElementRendererProps) => { messageId: rootMessage.id, conversationId: rootMessage.conversationId, ...rootMessage.data, - updatedData: updateMessages.map((message) => message.data), + ...(updateMessages.length > 0 && { + updatedData: updateMessages.map((message) => message.data), + }), }) ) : ( From 7632ba71012ed103318f4294a4e57716f586400e Mon Sep 17 00:00:00 2001 From: Eileen Li Date: Thu, 1 Aug 2024 10:38:11 -0700 Subject: [PATCH 7/7] fix: update based on suggestions --- .../messageSpace/messageSpace.cy.tsx | 13 +++-- .../messageSpace/messageSpace.stories.tsx | 6 ++- src/components/messageSpace/messageSpace.tsx | 50 +++++++++++-------- .../promptBuilder/promptBuilder.stories.tsx | 2 +- 4 files changed, 43 insertions(+), 28 deletions(-) diff --git a/src/components/messageSpace/messageSpace.cy.tsx b/src/components/messageSpace/messageSpace.cy.tsx index 6575d927..d9e30093 100644 --- a/src/components/messageSpace/messageSpace.cy.tsx +++ b/src/components/messageSpace/messageSpace.cy.tsx @@ -26,6 +26,8 @@ import { getMockWebSocketClient } from '../mockWebSocket' import MessageSpace from './messageSpace' describe('MessageSpace Component', () => { + const messageCanvas = '[data-cy=message-canvas]' + const supportedElements = { text: Text, streamingText: StreamingText, @@ -169,7 +171,7 @@ describe('MessageSpace Component', () => { { if (message.sender.name?.includes('Agent')) { @@ -202,14 +204,14 @@ describe('MessageSpace Component', () => { }) }) - it(`can receive and render messages from websocket on ${viewport} screen`, () => { + it.only(`can receive and render messages from websocket on ${viewport} screen`, () => { setupWebSocketServer() cy.viewport(viewport) cy.mount( { 'not.contain', 'Sure! The text is displayed progressively.' ) + cy.get(messageCanvas).should('have.length', 1) messagesToBeSent.forEach((message) => { cy.get(messageSpace).should('contain', message.data.text) }) @@ -244,6 +247,8 @@ describe('MessageSpace Component', () => { 'contain', 'Sure! The text is displayed progressively.' ) + const totalDisplayedMessages = 3 + cy.get(messageCanvas).should('have.length', totalDisplayedMessages) teardownWebSocketServer() }) @@ -262,7 +267,7 @@ describe('MessageSpace Component', () => { diff --git a/src/components/messageSpace/messageSpace.stories.tsx b/src/components/messageSpace/messageSpace.stories.tsx index 798ae630..d6eef4fe 100644 --- a/src/components/messageSpace/messageSpace.stories.tsx +++ b/src/components/messageSpace/messageSpace.stories.tsx @@ -65,7 +65,7 @@ function getProfileIconAndName(message: Message) { } meta.argTypes = { - messages: { + receivedMessages: { table: { type: { summary: 'Array of Message.\n', @@ -86,6 +86,8 @@ meta.argTypes = { }, }, ws: { + description: + 'WebSocket connection to send and receive messages to and from a backend. The onReceive prop will override the default handler once it is set. If you need to use the WebSocket for purposes other than chat, you will need to create a separate WebSocket connection.', table: { type: { summary: 'WebSocketClient', @@ -203,7 +205,7 @@ export const Default = { args: { ws: { send: () => {} }, sender: humanMessageData.sender, - messages: [ + receivedMessages: [ { ...humanMessageData, id: getUUID(), diff --git a/src/components/messageSpace/messageSpace.tsx b/src/components/messageSpace/messageSpace.tsx index 515b07f8..f1a2893d 100644 --- a/src/components/messageSpace/messageSpace.tsx +++ b/src/components/messageSpace/messageSpace.tsx @@ -18,7 +18,8 @@ export interface MessageSpaceProps extends MessageContainerProps { sender: Sender /** A component map contains message formats as keys and their corresponding React components as values. */ supportedElements: ComponentMap - messages?: Message[] + /** Messages received before the component was mounted. These messages are rendered along with new messages received from the websocket. */ + receivedMessages?: Message[] /** Text label for scroll down button. Default value is 'scroll down'. */ scrollDownLabel?: string } @@ -35,13 +36,28 @@ function getCombinedMessages( messages: { [key: string]: Message[] }, message: Message ) { - const key = message.format.includes('update') ? message.threadId : message.id + let key = message.format.includes('update') ? message.threadId : message.id + if (key) { + const newMessages = { ...messages } const existingMessages = messages[key] || [] - const newMessages = { - ...messages, - [key]: existingMessages.concat(message), + const originalMessage = existingMessages[0] + + // Check if sender is the same for update messages + if ( + message.format.includes('update') && + originalMessage && + originalMessage.sender.id !== message.sender.id + ) { + key = message.id + } + + if (!newMessages[key]) { + newMessages[key] = [] } + + newMessages[key] = newMessages[key].concat(message) + return newMessages } else { return messages @@ -51,7 +67,7 @@ function getCombinedMessages( /** The `MessageSpace` component uses `MessageCanvas` and `ElementRenderer` to render a list of messages. It serves as a container for individual message items, each encapsulated within a `MessageCanvas` for consistent styling and layout. It can receive and process messages to dynamically update the displayed content. - The `MessageSpace` component can combine update messages with the original message and render them as a single message. For this to work, the `threadId` of the update message must match the `id` of the original message, and the format of the update message must include 'update'. + The `MessageSpace` component can combine update messages with the original message and render them as a single message. For this to work, the `threadId` of the update message must match the `id` of the original message, and the format of the update message should be prefixed with 'update'. For example, if the original message format is 'streamingText', the update message format should be 'updateStreamingText'. Note: For more information about the `getActionsComponent` and `getProfileComponent` fields, refer to the [MessageCanvas' docs](http://localhost:6006/?path=/docs/rustic-ui-message-canvas-message-canvas--docs). */ @@ -82,14 +98,6 @@ export default function MessageSpace(props: MessageSpaceProps) { }, hideScrollButtonDuration) } - function handleScrollDown() { - scrollEndRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'end', - }) - hideScrollButton() - } - function getVideoStatus() { const videos = containerRef.current?.querySelectorAll('video') if (!videos || videos.length === 0) { @@ -128,7 +136,7 @@ export default function MessageSpace(props: MessageSpaceProps) { } }, [isScrolledToBottom]) - function scrollDownIfNeeded() { + function scrollDown() { if (getVideoStatus()) { const container = containerRef.current setAreVideosLoaded(true) @@ -140,12 +148,12 @@ export default function MessageSpace(props: MessageSpaceProps) { }, 0) } } else { - setTimeout(scrollDownIfNeeded, 1) + setTimeout(scrollDown, 1) } } useEffect(() => { - scrollDownIfNeeded() + scrollDown() }, [areVideosLoaded]) useEffect(() => { @@ -155,20 +163,20 @@ export default function MessageSpace(props: MessageSpaceProps) { if (isScrolledToBottom && hasNewMessage) { hideScrollButton() - scrollDownIfNeeded() + scrollDown() } }, [isScrolledToBottom, Object.keys(chatMessages).length]) useEffect(() => { let messageDict: { [messageId: string]: Message[] } = {} - props.messages?.forEach((message) => { + props.receivedMessages?.forEach((message) => { const newMessageDict = getCombinedMessages(messageDict, message) messageDict = newMessageDict }) setChatMessages(messageDict) - }, [props.messages?.length]) + }, [props.receivedMessages?.length]) function handleIncomingMessage(message: Message) { setChatMessages((prevMessages) => @@ -214,7 +222,7 @@ export default function MessageSpace(props: MessageSpaceProps) { variant="rusticSecondary" className="rustic-scroll-down-button" size="medium" - onClick={handleScrollDown} + onClick={scrollDown} label={ <> {props.scrollDownLabel} diff --git a/src/components/promptBuilder/promptBuilder.stories.tsx b/src/components/promptBuilder/promptBuilder.stories.tsx index 55f2d9e0..afea5603 100644 --- a/src/components/promptBuilder/promptBuilder.stories.tsx +++ b/src/components/promptBuilder/promptBuilder.stories.tsx @@ -301,7 +301,7 @@ export const Default = { ws={boilerplateWs} sender={user} supportedElements={{ text: Text }} - messages={messageSpaceMessages} + receivedMessages={messageSpaceMessages} getProfileComponent={(message) => { return <>{message.sender.name} }}